diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php index fdc8a437..13348d2a 100644 --- a/plugins/calendar/drivers/kolab/kolab_driver.php +++ b/plugins/calendar/drivers/kolab/kolab_driver.php @@ -1,2667 +1,2663 @@ * @author Aleksander Machniak * * Copyright (C) 2012-2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_driver extends calendar_driver { public const INVITATIONS_CALENDAR_PENDING = '--invitation--pending'; public const INVITATIONS_CALENDAR_DECLINED = '--invitation--declined'; // features this backend supports public $alarms = true; public $attendees = true; public $freebusy = true; public $attachments = true; public $undelete = true; public $alarm_types = ['DISPLAY', 'AUDIO']; public $categoriesimmutable = true; protected $rc; protected $cal; protected $calendars; protected $storage; protected $has_writeable = false; protected $freebusy_trigger = false; protected $bonnie_api = false; protected $search_more_results = false; /** * Default constructor */ public function __construct($cal) { $cal->require_plugin('libkolab'); // load helper classes *after* libkolab has been loaded (#3248) require_once(__DIR__ . '/kolab_calendar.php'); require_once(__DIR__ . '/kolab_user_calendar.php'); require_once(__DIR__ . '/kolab_invitation_calendar.php'); $this->cal = $cal; $this->rc = $cal->rc; $this->storage = new kolab_storage(); $this->cal->register_action('push-freebusy', [$this, 'push_freebusy']); $this->cal->register_action('calendar-acl', [$this, 'calendar_acl']); $this->freebusy_trigger = $this->rc->config->get('calendar_freebusy_trigger', false); if (!$this->rc->config->get('kolab_freebusy_server', false)) { $this->freebusy = false; } if (kolab_storage::$version == '2.0') { $this->alarm_types = ['DISPLAY']; $this->alarm_absolute = false; } // get configuration for the Bonnie API $this->bonnie_api = libkolab::get_bonnie_api(); // calendar uses fully encoded identifiers kolab_storage::$encode_ids = true; } /** * Read available calendars from server */ protected function _read_calendars() { // already read sources if (isset($this->calendars)) { return $this->calendars; } // get all folders that have "event" type, sorted by namespace/name $folders = $this->storage->sort_folders( $this->storage->get_folders('event') + kolab_storage::get_user_folders('event', true) ); $this->calendars = []; foreach ($folders as $folder) { $calendar = $this->_to_calendar($folder); if ($calendar->ready) { $this->calendars[$calendar->id] = $calendar; if ($calendar->editable) { $this->has_writeable = true; } } } return $this->calendars; } /** * Convert kolab_storage_folder into kolab_calendar * * @return kolab_calendar */ protected function _to_calendar($folder) { if ($folder instanceof kolab_calendar) { return $folder; } if ($folder instanceof kolab_storage_folder_user) { $calendar = new kolab_user_calendar($folder, $this->cal); $calendar->subscriptions = count($folder->children) > 0; } else { $calendar = new kolab_calendar($folder->name, $this->cal); } return $calendar; } /** * Get a list of available calendars from this source * * @param int $filter Bitmask defining filter criterias * @param ?kolab_storage_folder_virtual $tree Reference to hierarchical folder tree object * * @return array List of calendars */ public function list_calendars($filter = 0, &$tree = null) { $this->_read_calendars(); // attempt to create a default calendar for this user if (!$this->has_writeable) { if ($this->create_calendar(['name' => 'Calendar', 'color' => 'cc0000'])) { unset($this->calendars); $this->_read_calendars(); } } $delim = $this->rc->get_storage()->get_hierarchy_delimiter(); $folders = $this->filter_calendars($filter); $calendars = []; // include virtual folders for a full folder tree if (!is_null($tree)) { $folders = $this->storage->folder_hierarchy($folders, $tree); } $parents = array_keys($this->calendars); foreach ($folders as $id => $cal) { $imap_path = explode($delim, $cal->name); // find parent do { array_pop($imap_path); $parent_id = $this->storage->folder_id(implode($delim, $imap_path)); } while (count($imap_path) > 1 && !in_array($parent_id, $parents)); // restore "real" parent ID if ($parent_id && !in_array($parent_id, $parents)) { $parent_id = $this->storage->folder_id($cal->get_parent()); } $parents[] = $cal->id; if ($cal instanceof kolab_storage_folder_virtual) { $calendars[$cal->id] = [ 'id' => $cal->id, 'name' => $cal->get_name(), 'listname' => $cal->get_foldername(), 'editname' => $cal->get_foldername(), 'virtual' => true, 'editable' => false, 'group' => $cal->get_namespace(), ]; } else { // additional folders may come from kolab_storage::folder_hierarchy() above // make sure we deal with kolab_calendar instances $cal = $this->_to_calendar($cal); $this->calendars[$cal->id] = $cal; $is_user = ($cal instanceof kolab_user_calendar); $calendars[$cal->id] = [ 'id' => $cal->id, 'name' => $cal->get_name(), 'listname' => $cal->get_foldername(), 'editname' => $cal->get_foldername(), 'title' => $cal->get_title(), 'color' => $cal->get_color(), 'editable' => $cal->editable, 'group' => $is_user ? 'other user' : $cal->get_namespace(), 'active' => $cal->is_active(), 'owner' => $cal->get_owner(), 'removable' => !$cal->default, ]; if (!$is_user) { $calendars[$cal->id] += [ 'default' => $cal->default, 'rights' => $cal->rights, 'showalarms' => $cal->alarms, 'history' => !empty($this->bonnie_api), 'children' => true, // TODO: determine if that folder indeed has child folders 'parent' => $parent_id, 'subtype' => $cal->subtype, 'caldavurl' => $cal->get_caldav_url(), ]; } } if (!empty($cal->subscriptions)) { $calendars[$cal->id]['subscribed'] = $cal->is_subscribed(); } } // list virtual calendars showing invitations if ($this->rc->config->get('kolab_invitation_calendars') && !($filter & self::FILTER_INSERTABLE)) { foreach ([self::INVITATIONS_CALENDAR_PENDING, self::INVITATIONS_CALENDAR_DECLINED] as $id) { $cal = new kolab_invitation_calendar($id, $this->cal); if (!($filter & self::FILTER_ACTIVE) || $cal->is_active()) { $calendars[$id] = [ 'id' => $cal->id, 'name' => $cal->get_name(), 'listname' => $cal->get_name(), 'editname' => $cal->get_foldername(), 'title' => $cal->get_title(), 'color' => $cal->get_color(), 'editable' => $cal->editable, 'rights' => $cal->rights, 'showalarms' => $cal->alarms, 'history' => !empty($this->bonnie_api), 'group' => 'x-invitations', 'default' => false, 'active' => $cal->is_active(), 'owner' => $cal->get_owner(), 'children' => false, 'counts' => $id == self::INVITATIONS_CALENDAR_PENDING, ]; if (is_object($tree)) { $tree->children[] = $cal; } } } } // append the virtual birthdays calendar if ($this->rc->config->get('calendar_contact_birthdays', false) && !($filter & self::FILTER_INSERTABLE)) { $id = self::BIRTHDAY_CALENDAR_ID; $prefs = $this->rc->config->get('kolab_calendars', []); // read local prefs if (!($filter & self::FILTER_ACTIVE) || !empty($prefs[$id]['active'])) { $calendars[$id] = [ 'id' => $id, 'name' => $this->cal->gettext('birthdays'), 'listname' => $this->cal->gettext('birthdays'), 'color' => !empty($prefs[$id]['color']) ? $prefs[$id]['color'] : '87CEFA', 'active' => !empty($prefs[$id]['active']), 'showalarms' => (bool) $this->rc->config->get('calendar_birthdays_alarm_type'), 'group' => 'x-birthdays', 'editable' => false, 'default' => false, 'children' => false, 'history' => false, ]; } } return $calendars; } /** * Get list of calendars according to specified filters * * @param int $filter Bitmask defining restrictions. See FILTER_* constants for possible values. * * @return array List of calendars */ protected function filter_calendars($filter) { $this->_read_calendars(); $calendars = []; $plugin = $this->rc->plugins->exec_hook('calendar_list_filter', [ 'list' => $this->calendars, 'calendars' => $calendars, 'filter' => $filter, ]); if ($plugin['abort']) { return $plugin['calendars']; } $personal = $filter & self::FILTER_PERSONAL; $shared = $filter & self::FILTER_SHARED; foreach ($this->calendars as $cal) { if (!$cal->ready) { continue; } if (($filter & self::FILTER_WRITEABLE) && !$cal->editable) { continue; } if (($filter & self::FILTER_INSERTABLE) && !$cal->editable) { continue; } if (($filter & self::FILTER_ACTIVE) && !$cal->is_active()) { continue; } if (($filter & self::FILTER_PRIVATE) && $cal->subtype != 'private') { continue; } if (($filter & self::FILTER_CONFIDENTIAL) && $cal->subtype != 'confidential') { continue; } if ($personal || $shared) { $ns = $cal->get_namespace(); if (!(($personal && $ns == 'personal') || ($shared && $ns == 'shared'))) { continue; } } $calendars[$cal->id] = $cal; } return $calendars; } /** * Get the kolab_calendar instance for the given calendar ID * * @param string $id Calendar identifier (encoded imap folder name) * * @return kolab_calendar|kolab_invitation_calendar|null Object or null if calendar doesn't exist */ public function get_calendar($id) { $this->_read_calendars(); // create calendar object if necesary if (empty($this->calendars[$id])) { if (in_array($id, [self::INVITATIONS_CALENDAR_PENDING, self::INVITATIONS_CALENDAR_DECLINED])) { return new kolab_invitation_calendar($id, $this->cal); } // for unsubscribed calendar folders if ($id !== self::BIRTHDAY_CALENDAR_ID) { $calendar = kolab_calendar::factory($id, $this->cal); if ($calendar->ready) { $this->calendars[$calendar->id] = $calendar; } } } return !empty($this->calendars[$id]) ? $this->calendars[$id] : null; } /** * Get a calendar name for the given calendar ID * * @param string $id Calendar identifier * * @return string|null Calendar name if found */ public function get_calendar_name($id) { $cal = $this->get_calendar($id); return $cal ? $cal->get_name() : null; } /** * Create a new calendar assigned to the current user * * @param array $prop Hash array with calendar properties * name: Calendar name * color: The color of the calendar * * @return mixed ID of the calendar on success, False on error */ public function create_calendar($prop) { $prop['type'] = 'event'; $prop['active'] = true; $prop['subscribed'] = true; $folder = $this->storage->folder_update($prop); if ($folder === false) { $this->last_error = $this->cal->gettext($this->storage::$last_error); return false; } // create ID $id = $this->storage->folder_id($folder); // save color in user prefs (temp. solution) $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', []); if (isset($prop['color'])) { $prefs['kolab_calendars'][$id]['color'] = $prop['color']; } if (isset($prop['showalarms'])) { $prefs['kolab_calendars'][$id]['showalarms'] = !empty($prop['showalarms']); } if (!empty($prefs['kolab_calendars'][$id])) { $this->rc->user->save_prefs($prefs); } return $id; } /** * Update properties of an existing calendar * * @see calendar_driver::edit_calendar() */ public function edit_calendar($prop) { if (!empty($prop['id']) && ($cal = $this->get_calendar($prop['id']))) { $id = $cal->update($prop); } else { $id = $prop['id']; } // fallback to local prefs $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', []); unset($prefs['kolab_calendars'][$prop['id']]['color'], $prefs['kolab_calendars'][$prop['id']]['showalarms']); if (isset($prop['color'])) { $prefs['kolab_calendars'][$id]['color'] = $prop['color']; } if (isset($prop['showalarms']) && $id == self::BIRTHDAY_CALENDAR_ID) { $prefs['calendar_birthdays_alarm_type'] = $prop['showalarms'] ? $this->alarm_types[0] : ''; } elseif (isset($prop['showalarms'])) { $prefs['kolab_calendars'][$id]['showalarms'] = !empty($prop['showalarms']); } if (!empty($prefs['kolab_calendars'][$id])) { $this->rc->user->save_prefs($prefs); } return true; } /** * Set active/subscribed state of a calendar * * @see calendar_driver::subscribe_calendar() */ public function subscribe_calendar($prop) { if (!empty($prop['id']) && ($cal = $this->get_calendar($prop['id'])) && !empty($cal->storage)) { $ret = false; if (isset($prop['permanent'])) { $ret |= $cal->storage->subscribe(intval($prop['permanent'])); } if (isset($prop['active'])) { $ret |= $cal->storage->activate(intval($prop['active'])); } // apply to child folders, too if (!empty($prop['recursive'])) { foreach ((array) $this->storage->list_folders($cal->storage->name, '*', 'event') as $subfolder) { if (isset($prop['permanent'])) { if ($prop['permanent']) { $this->storage->folder_subscribe($subfolder); } else { $this->storage->folder_unsubscribe($subfolder); } } if (isset($prop['active'])) { if ($prop['active']) { $this->storage->folder_activate($subfolder); } else { $this->storage->folder_deactivate($subfolder); } } } } return $ret; } // save state in local prefs $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', []); $prefs['kolab_calendars'][$prop['id']]['active'] = !empty($prop['active']); $this->rc->user->save_prefs($prefs); return true; } /** * Delete the given calendar with all its contents * * @see calendar_driver::delete_calendar() */ public function delete_calendar($prop) { if (!empty($prop['id']) && ($cal = $this->get_calendar($prop['id']))) { $folder = $cal->get_realname(); // TODO: unsubscribe if no admin rights if ($this->storage->folder_delete($folder)) { // remove color in user prefs (temp. solution) $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', []); unset($prefs['kolab_calendars'][$prop['id']]); $this->rc->user->save_prefs($prefs); return true; } else { $this->last_error = $this->storage->last_error; } } return false; } /** * Search for shared or otherwise not listed calendars the user has access * * @param string $query Search string * @param string $source Section/source to search * * @return array List of calendars */ public function search_calendars($query, $source) { if (!$this->storage->setup()) { return []; } $this->calendars = []; $this->search_more_results = false; // find unsubscribed IMAP folders that have "event" type if ($source == 'folders') { foreach ((array) $this->storage->search_folders('event', $query, ['other']) as $folder) { $calendar = new kolab_calendar($folder->name, $this->cal); $this->calendars[$calendar->id] = $calendar; } } // find other user's virtual calendars elseif ($source == 'users') { // we have slightly more space, so display twice the number $limit = $this->rc->config->get('autocomplete_max', 15) * 2; $count = 0; foreach ($this->storage->search_users($query, 0, [], $limit, $count) as $user) { $calendar = new kolab_user_calendar($user, $this->cal); $this->calendars[$calendar->id] = $calendar; // search for calendar folders shared by this user foreach ($this->storage->list_user_folders($user, 'event', false) as $foldername) { $cal = new kolab_calendar($foldername, $this->cal); $this->calendars[$cal->id] = $cal; $calendar->subscriptions = true; } } if ($count > $limit) { $this->search_more_results = true; } } // don't list the birthday calendar $this->rc->config->set('calendar_contact_birthdays', false); $this->rc->config->set('kolab_invitation_calendars', false); return $this->list_calendars(); } /** * Fetch a single event * * @see calendar_driver::get_event() * @return ?array Hash array with event properties, false if not found */ public function get_event($event, $scope = 0, $full = false) { if (is_array($event)) { $id = !empty($event['id']) ? $event['id'] : $event['uid']; $cal = $event['calendar'] ?? null; // we're looking for a recurring instance: expand the ID to our internal convention for recurring instances if (empty($event['id']) && !empty($event['_instance'])) { $id .= '-' . $event['_instance']; } } else { $id = $event; } if (!empty($cal)) { if ($storage = $this->get_calendar($cal)) { $result = $storage->get_event($id); return self::to_rcube_event($result); } // get event from the address books birthday calendar if ($cal == self::BIRTHDAY_CALENDAR_ID) { return $this->get_birthday_event($id); } } // iterate over all calendar folders and search for the event ID else { foreach ($this->filter_calendars($scope) as $calendar) { if ($result = $calendar->get_event($id)) { return self::to_rcube_event($result); } } } return null; } /** * Add a single event to the database * * @see calendar_driver::new_event() */ public function new_event($event) { if (!$this->validate($event)) { return false; } $event = self::from_rcube_event($event); if (!$event['calendar']) { $this->_read_calendars(); $cal_ids = array_keys($this->calendars); $event['calendar'] = reset($cal_ids); } if ($storage = $this->get_calendar($event['calendar'])) { // if this is a recurrence instance, append as exception to an already existing object for this UID if (!empty($event['recurrence_date']) && ($master = $storage->get_event($event['uid']))) { self::add_exception($master, $event); $success = $storage->update_event($master); } else { $success = $storage->insert_event($event); } if ($success && $this->freebusy_trigger) { $this->rc->output->command('plugin.ping_url', ['action' => 'calendar/push-freebusy', 'source' => $storage->id]); $this->freebusy_trigger = false; // disable after first execution (#2355) } return $success; } return false; } /** * Update an event entry with the given data * * @see calendar_driver::new_event() * @return bool True on success, False on error */ public function edit_event($event) { if (!($storage = $this->get_calendar($event['calendar']))) { return false; } return $this->update_event(self::from_rcube_event($event, $storage->get_event($event['id']))); } /** * Extended event editing with possible changes to the argument * * @param array $event Hash array with event properties * @param string $status New participant status * @param array $attendees List of hash arrays with updated attendees * * @return bool True on success, False on error */ public function edit_rsvp(&$event, $status, $attendees) { $update_event = $event; // apply changes to master (and all exceptions) if ($event['_savemode'] == 'all' && !empty($event['recurrence_id'])) { if ($storage = $this->get_calendar($event['calendar'])) { $update_event = $storage->get_event($event['recurrence_id']); $update_event['_savemode'] = $event['_savemode']; $update_event['id'] = $update_event['uid']; unset($update_event['recurrence_id']); calendar::merge_attendee_data($update_event, $attendees); } } if ($ret = $this->update_attendees($update_event, $attendees)) { // replace with master event (for iTip reply) $event = self::to_rcube_event($update_event); // re-assign to the according (virtual) calendar if ($this->rc->config->get('kolab_invitation_calendars')) { if (strtoupper($status) == 'DECLINED') { $event['calendar'] = self::INVITATIONS_CALENDAR_DECLINED; } elseif (strtoupper($status) == 'NEEDS-ACTION') { $event['calendar'] = self::INVITATIONS_CALENDAR_PENDING; } elseif (!empty($event['_folder_id'])) { $event['calendar'] = $event['_folder_id']; } } } return $ret; } /** * Update the participant status for the given attendees * * @see calendar_driver::update_attendees() */ public function update_attendees(&$event, $attendees) { // for this-and-future updates, merge the updated attendees onto all exceptions in range if ( ($event['_savemode'] == 'future' && !empty($event['recurrence_id'])) || (!empty($event['recurrence']) && empty($event['recurrence_id'])) ) { if (!($storage = $this->get_calendar($event['calendar']))) { return false; } // load master event $master = !empty($event['recurrence_id']) ? $storage->get_event($event['recurrence_id']) : $event; // apply attendee update to each existing exception if (!empty($master['recurrence']) && !empty($master['recurrence']['EXCEPTIONS'])) { $saved = false; foreach ($master['recurrence']['EXCEPTIONS'] as $i => $exception) { // merge the new event properties onto future exceptions if ($exception['_instance'] >= strval($event['_instance'])) { calendar::merge_attendee_data($master['recurrence']['EXCEPTIONS'][$i], $attendees); } // update a specific instance if ($exception['_instance'] == $event['_instance'] && $exception['thisandfuture']) { $saved = true; } } // add the given event as new exception if (!$saved && $event['id'] != $master['id']) { $event['thisandfuture'] = true; $master['recurrence']['EXCEPTIONS'][] = $event; } // set link to top-level exceptions $master['exceptions'] = &$master['recurrence']['EXCEPTIONS']; return $this->update_event($master); } } // just update the given event (instance) return $this->update_event($event); } /** * Move a single event * * @see calendar_driver::move_event() * @return bool True on success, False on error */ public function move_event($event) { if (($storage = $this->get_calendar($event['calendar'])) && ($ev = $storage->get_event($event['id']))) { unset($ev['sequence']); self::clear_attandee_noreply($ev); return $this->update_event($event + $ev); } return false; } /** * Resize a single event * * @see calendar_driver::resize_event() * @return bool True on success, False on error */ public function resize_event($event) { if (($storage = $this->get_calendar($event['calendar'])) && ($ev = $storage->get_event($event['id']))) { unset($ev['sequence']); self::clear_attandee_noreply($ev); return $this->update_event($event + $ev); } return false; } /** * Remove a single event * * @param array $event Hash array with event properties: * - id: Event identifier * @param bool $force Remove record(s) irreversible (mark as deleted otherwise) * * @return bool True on success, False on error */ public function remove_event($event, $force = true) { $ret = true; $success = false; $savemode = $event['_savemode'] ?? null; if (!$force) { unset($event['attendees']); $this->rc->session->remove('calendar_event_undo'); $this->rc->session->remove('calendar_restore_event_data'); $sess_data = $event; } if (($storage = $this->get_calendar($event['calendar'])) && ($event = $storage->get_event($event['id']))) { $event['_savemode'] = $savemode; $decline = !empty($event['_decline']); $savemode = 'all'; $master = $event; // read master if deleting a recurring event if (!empty($event['recurrence']) || !empty($event['recurrence_id']) || !empty($event['isexception'])) { $master = $storage->get_event($event['uid']); if (!empty($event['_savemode'])) { $savemode = $event['_savemode']; } elseif (!empty($event['_instance']) || !empty($event['isexception'])) { $savemode = 'current'; } // force 'current' mode for single occurrences stored as exception if (empty($event['recurrence']) && empty($event['recurrence_id']) && !empty($event['isexception'])) { $savemode = 'current'; } } // removing an exception instance if ((!empty($event['recurrence_id']) || !empty($event['isexception'])) && !empty($master['exceptions'])) { foreach ($master['exceptions'] as $i => $exception) { if (libcalendaring::is_recurrence_exception($event, $exception)) { unset($master['exceptions'][$i]); // set event date back to the actual occurrence if (!empty($exception['recurrence_date'])) { $event['start'] = $exception['recurrence_date']; } } } if (!empty($master['recurrence'])) { $master['recurrence']['EXCEPTIONS'] = &$master['exceptions']; } } switch ($savemode) { case 'current': $_SESSION['calendar_restore_event_data'] = serialize(array_diff_key($master, ['_formatobj' => 1])); // remove the matching RDATE entry if (!empty($master['recurrence']['RDATE'])) { foreach ($master['recurrence']['RDATE'] as $j => $rdate) { if ($rdate->format('Ymd') == $event['start']->format('Ymd')) { unset($master['recurrence']['RDATE'][$j]); break; } } } // add exception to master event $master['recurrence']['EXDATE'][] = $event['start']; $success = $storage->update_event($master); break; case 'future': $master['_instance'] = libcalendaring::recurrence_instance_identifier($master); if ($master['_instance'] != $event['_instance']) { $_SESSION['calendar_restore_event_data'] = serialize(array_diff_key($master, ['_formatobj' => 1])); // set until-date on master event $master['recurrence']['UNTIL'] = clone $event['start']; $master['recurrence']['UNTIL']->sub(new DateInterval('P1D')); unset($master['recurrence']['COUNT']); // if all future instances are deleted, remove recurrence rule entirely (bug #1677) if ($master['recurrence']['UNTIL']->format('Ymd') == $master['start']->format('Ymd')) { $master['recurrence'] = []; } // remove matching RDATE entries elseif (!empty($master['recurrence']['RDATE'])) { foreach ($master['recurrence']['RDATE'] as $j => $rdate) { if ($rdate->format('Ymd') == $event['start']->format('Ymd')) { $master['recurrence']['RDATE'] = array_slice($master['recurrence']['RDATE'], 0, $j); break; } } } $success = $storage->update_event($master); $ret = $master['uid']; break; } // no break default: // 'all' is default // removing the master event with loose exceptions (not recurring though) if (!empty($event['recurrence_date']) && empty($master['recurrence']) && !empty($master['exceptions'])) { // make the first exception the new master $newmaster = array_shift($master['exceptions']); $newmaster['exceptions'] = $master['exceptions']; $newmaster['_attachments'] = $master['_attachments']; $newmaster['_mailbox'] = $master['_mailbox']; $newmaster['_msguid'] = $master['_msguid']; $success = $storage->update_event($newmaster); } elseif ($decline && $this->rc->config->get('kolab_invitation_calendars')) { // don't delete but set PARTSTAT=DECLINED if ($this->cal->lib->set_partstat($master, 'DECLINED')) { $success = $storage->update_event($master); } } if (!$success) { $success = $storage->delete_event($master, $force); } break; } } if ($success && !$force) { if (!empty($master['_folder_id'])) { $sess_data['_folder_id'] = $master['_folder_id']; } $_SESSION['calendar_event_undo'] = ['ts' => time(), 'data' => $sess_data]; } if ($success && $this->freebusy_trigger) { $this->rc->output->command('plugin.ping_url', [ 'action' => 'calendar/push-freebusy', // _folder_id may be set by invitations calendar 'source' => !empty($master['_folder_id']) ? $master['_folder_id'] : $storage->id, ]); } return $success ? $ret : false; } /** * Restore a single deleted event * * @param array $event Hash array with event properties: * - id: Event identifier * - calendar: Event calendar * * @return bool True on success, False on error */ public function restore_event($event) { if ($storage = $this->get_calendar($event['calendar'])) { if (!empty($_SESSION['calendar_restore_event_data'])) { $success = $storage->update_event($event = unserialize($_SESSION['calendar_restore_event_data'])); } else { $success = $storage->restore_event($event); } if ($success && $this->freebusy_trigger) { $this->rc->output->command('plugin.ping_url', [ 'action' => 'calendar/push-freebusy', // _folder_id may be set by invitations calendar 'source' => !empty($event['_folder_id']) ? $event['_folder_id'] : $storage->id, ]); } return $success; } return false; } /** * Wrapper to update an event object depending on the given savemode */ protected function update_event($event) { if (!($storage = $this->get_calendar($event['calendar']))) { return false; } // move event to another folder/calendar if (!empty($event['_fromcalendar']) && $event['_fromcalendar'] != $event['calendar']) { if (!($fromcalendar = $this->get_calendar($event['_fromcalendar']))) { return false; } $old = $fromcalendar->get_event($event['id']); if (empty($event['_savemode']) || $event['_savemode'] != 'new') { if (!$fromcalendar->storage->move($old['uid'], $storage->storage)) { return false; } } } $success = false; $savemode = 'all'; $old = $master = $storage->get_event($event['id']); if (!$old || empty($old['start'])) { rcube::raise_error( [ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, 'message' => "Failed to load event object to update: id=" . $event['id'], ], true, false ); return false; } // modify a recurring event, check submitted savemode to do the right things if (!empty($old['recurrence']) || !empty($old['recurrence_id']) || !empty($old['isexception'])) { $master = $storage->get_event($old['uid']); if (!empty($event['_savemode'])) { $savemode = $event['_savemode']; } else { $savemode = (!empty($old['recurrence_id']) || !empty($old['isexception'])) ? 'current' : 'all'; } // this-and-future on the first instance equals to 'all' if ($savemode == 'future' && !empty($master['start']) && $old['_instance'] == libcalendaring::recurrence_instance_identifier($master) ) { $savemode = 'all'; } // force 'current' mode for single occurrences stored as exception elseif (empty($old['recurrence']) && empty($old['recurrence_id']) && !empty($old['isexception'])) { $savemode = 'current'; } // Stick to the master timezone for all occurrences (Bifrost#T104637) if (empty($master['allday']) || !empty($event['allday'])) { $master_tz = $master['start']->getTimezone(); $event_tz = $event['start']->getTimezone(); if ($master_tz->getName() != $event_tz->getName()) { $event['start']->setTimezone($master_tz); $event['end']->setTimezone($master_tz); } } } // check if update affects scheduling and update attendee status accordingly $reschedule = $this->check_scheduling($event, $old, true); // keep saved exceptions (not submitted by the client) if (!empty($old['recurrence']['EXDATE']) && !isset($event['recurrence']['EXDATE'])) { $event['recurrence']['EXDATE'] = $old['recurrence']['EXDATE']; } if (isset($event['recurrence']['EXCEPTIONS'])) { // exceptions already provided (e.g. from iCal import) $with_exceptions = true; } elseif (!empty($old['recurrence']['EXCEPTIONS'])) { $event['recurrence']['EXCEPTIONS'] = $old['recurrence']['EXCEPTIONS']; } elseif (!empty($old['exceptions'])) { $event['exceptions'] = $old['exceptions']; } // remove some internal properties which should not be saved unset($event['_savemode'], $event['_fromcalendar'], $event['_identity'], $event['_owner'], $event['_notify'], $event['_method'], $event['_sender'], $event['_sender_utf'], $event['_size'] ); switch ($savemode) { case 'new': // save submitted data as new (non-recurring) event $event['recurrence'] = []; $event['_copyfrom'] = $master['_msguid']; $event['_mailbox'] = $master['_mailbox']; $event['uid'] = $this->cal->generate_uid(); unset($event['recurrence_id'], $event['recurrence_date'], $event['_instance'], $event['id']); // copy attachment metadata to new event $event = self::from_rcube_event($event, $master); self::clear_attandee_noreply($event); if ($success = $storage->insert_event($event)) { $success = $event['uid']; } break; case 'future': // create a new recurring event $event['_copyfrom'] = $master['_msguid']; $event['_mailbox'] = $master['_mailbox']; $event['uid'] = $this->cal->generate_uid(); unset($event['recurrence_id'], $event['recurrence_date'], $event['_instance'], $event['id']); // copy attachment metadata to new event $event = self::from_rcube_event($event, $master); // remove recurrence exceptions on re-scheduling if ($reschedule) { unset($event['recurrence']['EXCEPTIONS'], $event['exceptions'], $master['recurrence']['EXDATE']); } else { if (isset($event['recurrence']['EXCEPTIONS']) && is_array($event['recurrence']['EXCEPTIONS'])) { // only keep relevant exceptions $event['recurrence']['EXCEPTIONS'] = array_filter( $event['recurrence']['EXCEPTIONS'], function ($exception) use ($event) { return $exception['start'] > $event['start']; } ); // set link to top-level exceptions $event['exceptions'] = &$event['recurrence']['EXCEPTIONS']; } if (isset($event['recurrence']['EXDATE']) && is_array($event['recurrence']['EXDATE'])) { $event['recurrence']['EXDATE'] = array_filter( $event['recurrence']['EXDATE'], function ($exdate) use ($event) { return $exdate > $event['start']; } ); } } // compute remaining occurrences if ($event['recurrence']['COUNT']) { if (empty($old['_count'])) { $old['_count'] = $this->get_recurrence_count($master, $old['start']); } $event['recurrence']['COUNT'] -= intval($old['_count']); } // remove fixed weekday when date changed if ($old['start']->format('Y-m-d') != $event['start']->format('Y-m-d')) { if (!empty($event['recurrence']['BYDAY']) && strlen($event['recurrence']['BYDAY']) == 2) { unset($event['recurrence']['BYDAY']); } if (!empty($old['recurrence']['BYMONTH']) && $old['recurrence']['BYMONTH'] == $old['start']->format('n')) { unset($event['recurrence']['BYMONTH']); } } // set until-date on master event $master['recurrence']['UNTIL'] = clone $old['start']; $master['recurrence']['UNTIL']->sub(new DateInterval('P1D')); unset($master['recurrence']['COUNT']); // remove all exceptions after $event['start'] if (isset($master['recurrence']['EXCEPTIONS']) && is_array($master['recurrence']['EXCEPTIONS'])) { $master['recurrence']['EXCEPTIONS'] = array_filter( $master['recurrence']['EXCEPTIONS'], function ($exception) use ($event) { return $exception['start'] < $event['start']; } ); // set link to top-level exceptions $master['exceptions'] = &$master['recurrence']['EXCEPTIONS']; } if (isset($master['recurrence']['EXDATE']) && is_array($master['recurrence']['EXDATE'])) { $master['recurrence']['EXDATE'] = array_filter( $master['recurrence']['EXDATE'], function ($exdate) use ($event) { return $exdate < $event['start']; } ); } // save new event if ($success = $storage->insert_event($event)) { $success = $event['uid']; // update master event (no rescheduling!) self::clear_attandee_noreply($master); $storage->update_event($master); } break; case 'current': // recurring instances shall not store recurrence rules and attachments $event['recurrence'] = []; $event['thisandfuture'] = $savemode == 'future'; unset($event['attachments'], $event['id']); // increment sequence of this instance if scheduling is affected if ($reschedule) { $event['sequence'] = max($old['sequence'] ?? 0, $master['sequence'] ?? 0) + 1; } elseif (!isset($event['sequence'])) { $event['sequence'] = !empty($old['sequence']) ? $old['sequence'] : $master['sequence'] ?? 1; } // save properties to a recurrence exception instance if (!empty($old['_instance']) && isset($master['recurrence']['EXCEPTIONS'])) { if ($this->update_recurrence_exceptions($master, $event, $old, $savemode)) { $success = $storage->update_event($master, $old['id'] ?? null); break; } } $add_exception = true; // adjust matching RDATE entry if dates changed if ( !empty($master['recurrence']['RDATE']) && ($old_date = $old['start']->format('Ymd')) != $event['start']->format('Ymd') ) { foreach ($master['recurrence']['RDATE'] as $j => $rdate) { if ($rdate->format('Ymd') == $old_date) { $master['recurrence']['RDATE'][$j] = $event['start']; sort($master['recurrence']['RDATE']); $add_exception = false; break; } } } // save as new exception to master event if ($add_exception) { self::add_exception($master, $event, $old); } $success = $storage->update_event($master); break; default: // 'all' is the default $event['id'] = $master['uid']; $event['uid'] = $master['uid']; // use start date from master but try to be smart on time or duration changes $old_start_date = $old['start']->format('Y-m-d'); $old_start_time = !empty($old['allday']) ? '' : $old['start']->format('H:i'); $old_duration = self::event_duration($old['start'], $old['end'], !empty($old['allday'])); $new_start_date = $event['start']->format('Y-m-d'); $new_start_time = !empty($event['allday']) ? '' : $event['start']->format('H:i'); $new_duration = self::event_duration($event['start'], $event['end'], !empty($event['allday'])); $diff = $old_start_date != $new_start_date || $old_start_time != $new_start_time || $old_duration != $new_duration; $date_shift = $old['start']->diff($event['start']); // shifted or resized if ($diff && ($old_start_date == $new_start_date || $old_duration == $new_duration)) { $event['start'] = $master['start']->add($date_shift); $event['end'] = clone $event['start']; $event['end']->add(new DateInterval($new_duration)); // remove fixed weekday, will be re-set to the new weekday in kolab_calendar::update_event() if ($old_start_date != $new_start_date && !empty($event['recurrence'])) { if (!empty($event['recurrence']['BYDAY']) && strlen($event['recurrence']['BYDAY']) == 2) { unset($event['recurrence']['BYDAY']); } if (!empty($old['recurrence']['BYMONTH']) && $old['recurrence']['BYMONTH'] == $old['start']->format('n')) { unset($event['recurrence']['BYMONTH']); } } } // dates did not change, use the ones from master elseif ($new_start_date . $new_start_time == $old_start_date . $old_start_time) { $event['start'] = $master['start']; $event['end'] = $master['end']; } // when saving an instance in 'all' mode, copy recurrence exceptions over if (!empty($old['recurrence_id'])) { $event['recurrence']['EXCEPTIONS'] = $master['recurrence']['EXCEPTIONS'] ?? []; $event['recurrence']['EXDATE'] = $master['recurrence']['EXDATE'] ?? []; } elseif (!empty($master['_instance'])) { $event['_instance'] = $master['_instance']; $event['recurrence_date'] = $master['recurrence_date']; } // TODO: forward changes to exceptions (which do not yet have differing values stored) if (!empty($event['recurrence']) && !empty($event['recurrence']['EXCEPTIONS']) && empty($with_exceptions)) { // determine added and removed attendees $old_attendees = $current_attendees = $added_attendees = []; if (!empty($old['attendees'])) { foreach ((array) $old['attendees'] as $attendee) { $old_attendees[] = $attendee['email']; } } if (!empty($event['attendees'])) { foreach ((array) $event['attendees'] as $attendee) { $current_attendees[] = $attendee['email']; if (!in_array($attendee['email'], $old_attendees)) { $added_attendees[] = $attendee; } } } $removed_attendees = array_diff($old_attendees, $current_attendees); foreach ($event['recurrence']['EXCEPTIONS'] as $i => $exception) { calendar::merge_attendee_data($event['recurrence']['EXCEPTIONS'][$i], $added_attendees, $removed_attendees); } // adjust recurrence-id when start changed and therefore the entire recurrence chain changes if ($old_start_date != $new_start_date || $old_start_time != $new_start_time) { $recurrence_id_format = libcalendaring::recurrence_id_format($event); foreach ($event['recurrence']['EXCEPTIONS'] as $i => $exception) { if (isset($exception['recurrence_date']) && $exception['recurrence_date'] instanceof DateTimeInterface) { $recurrence_id = $exception['recurrence_date']; } else { $recurrence_id = rcube_utils::anytodatetime($exception['_instance'], $old['start']->getTimezone()); } if ($recurrence_id instanceof DateTime || $recurrence_id instanceof DateTimeImmutable) { $recurrence_id->add($date_shift); $event['recurrence']['EXCEPTIONS'][$i]['recurrence_date'] = $recurrence_id; $event['recurrence']['EXCEPTIONS'][$i]['_instance'] = $recurrence_id->format($recurrence_id_format); } } } // set link to top-level exceptions $event['exceptions'] = &$event['recurrence']['EXCEPTIONS']; } // unset _dateonly flags in (cached) date objects unset($event['start']->_dateonly, $event['end']->_dateonly); $success = $storage->update_event($event) ? $event['id'] : false; // return master UID break; } if ($success && $this->freebusy_trigger) { $this->rc->output->command('plugin.ping_url', [ 'action' => 'calendar/push-freebusy', 'source' => $storage->id, ]); } return $success; } /** * Calculate event duration, returns string in DateInterval format */ protected static function event_duration($start, $end, $allday = false) { if ($allday) { $diff = $start->diff($end); return 'P' . $diff->days . 'D'; } return 'PT' . ($end->format('U') - $start->format('U')) . 'S'; } /** * Determine whether the current change affects scheduling and reset attendee status accordingly */ protected function check_scheduling(&$event, $old, $update = true) { // skip this check when importing iCal/iTip events if (isset($event['sequence']) || !empty($event['_method'])) { return false; } // iterate through the list of properties considered 'significant' for scheduling $kolab_event = !empty($old['_formatobj']) ? $old['_formatobj'] : new kolab_format_event(); $reschedule = $kolab_event->check_rescheduling($event, $old); // reset all attendee status to needs-action (#4360) if ($update && $reschedule && !empty($event['attendees'])) { $is_organizer = false; $emails = $this->cal->get_user_emails(); $attendees = $event['attendees']; foreach ($attendees as $i => $attendee) { if ($attendee['role'] == 'ORGANIZER' && !empty($attendee['email']) && in_array(strtolower($attendee['email']), $emails) ) { $is_organizer = true; } elseif ($attendee['role'] != 'ORGANIZER' && $attendee['role'] != 'NON-PARTICIPANT' && $attendee['status'] != 'DELEGATED' ) { $attendees[$i]['status'] = 'NEEDS-ACTION'; $attendees[$i]['rsvp'] = true; } } // update attendees only if I'm the organizer if ($is_organizer || (!empty($event['organizer']) && in_array(strtolower($event['organizer']['email']), $emails))) { $event['attendees'] = $attendees; } } return $reschedule; } /** * Apply the given changes to already existing exceptions */ protected function update_recurrence_exceptions(&$master, $event, $old, $savemode) { $saved = false; $existing = null; // determine added and removed attendees $added_attendees = $removed_attendees = []; if ($savemode == 'future') { $old_attendees = $current_attendees = []; if (!empty($old['attendees'])) { foreach ((array) $old['attendees'] as $attendee) { $old_attendees[] = $attendee['email']; } } if (!empty($event['attendees'])) { foreach ((array) $event['attendees'] as $attendee) { $current_attendees[] = $attendee['email']; if (!in_array($attendee['email'], $old_attendees)) { $added_attendees[] = $attendee; } } } $removed_attendees = array_diff($old_attendees, $current_attendees); } foreach ($master['recurrence']['EXCEPTIONS'] as $i => $exception) { // update a specific instance if (libcalendaring::is_recurrence_exception($old, $exception)) { $existing = $i; // check savemode against existing exception mode. // if matches, we can update this existing exception $thisandfuture = !empty($exception['thisandfuture']); if ($thisandfuture === ($savemode == 'future')) { $event['_instance'] = $old['_instance']; $event['thisandfuture'] = !empty($old['thisandfuture']); $event['recurrence_date'] = $old['recurrence_date']; $master['recurrence']['EXCEPTIONS'][$i] = $event; $saved = true; } } // merge the new event properties onto future exceptions if ($savemode == 'future') { $exception_instance = libcalendaring::recurrence_instance_identifier($exception, true); $old_instance = libcalendaring::recurrence_instance_identifier($old, true); if ($exception_instance >= $old_instance) { unset($event['thisandfuture']); self::merge_exception_data($master['recurrence']['EXCEPTIONS'][$i], $event, ['attendees']); if (!empty($added_attendees) || !empty($removed_attendees)) { calendar::merge_attendee_data($master['recurrence']['EXCEPTIONS'][$i], $added_attendees, $removed_attendees); } } } } /* // we could not update the existing exception due to savemode mismatch... if (!$saved && isset($existing) && !empty($master['recurrence']['EXCEPTIONS'][$existing]['thisandfuture'])) { // ... try to move the existing this-and-future exception to the next occurrence foreach ($this->get_recurring_events($master, $existing['start']) as $candidate) { // our old this-and-future exception is obsolete if (!empty($candidate['thisandfuture'])) { unset($master['recurrence']['EXCEPTIONS'][$existing]); $saved = true; break; } // this occurrence doesn't yet have an exception else if (empty($candidate['isexception'])) { $event['_instance'] = $candidate['_instance']; $event['recurrence_date'] = $candidate['recurrence_date']; $master['recurrence']['EXCEPTIONS'][$i] = $event; $saved = true; break; } } } */ // set link to top-level exceptions $master['exceptions'] = &$master['recurrence']['EXCEPTIONS']; // returning false here will add a new exception return $saved; } /** * Add or update the given event as an exception to $master */ public static function add_exception(&$master, $event, $old = null) { if ($old) { $event['_instance'] = $old['_instance'] ?? null; if (empty($event['recurrence_date'])) { $event['recurrence_date'] = !empty($old['recurrence_date']) ? $old['recurrence_date'] : $old['start']; } } elseif (empty($event['recurrence_date'])) { $event['recurrence_date'] = $event['start']; } if (!isset($master['exceptions'])) { if (isset($master['recurrence']['EXCEPTIONS'])) { $master['exceptions'] = &$master['recurrence']['EXCEPTIONS']; } else { $master['exceptions'] = []; } } $existing = false; foreach ($master['exceptions'] as $i => $exception) { if (libcalendaring::is_recurrence_exception($event, $exception)) { $master['exceptions'][$i] = $event; $existing = true; } } if (!$existing) { $master['exceptions'][] = $event; } return true; } /** * Remove the noreply flags from attendees */ public static function clear_attandee_noreply(&$event) { if (!empty($event['attendees'])) { foreach ((array) $event['attendees'] as $i => $attendee) { unset($event['attendees'][$i]['noreply']); } } } /** * Merge certain properties from the overlay event to the base event object * * @param array $event The event object to be altered * @param array $overlay The overlay event object to be merged over $event * @param ?array $blacklist List of properties not allowed to be overwritten */ public static function merge_exception_data(&$event, $overlay, $blacklist = null) { $forbidden = ['id','uid','recurrence','recurrence_date','thisandfuture','organizer','_attachments']; if (is_array($blacklist)) { $forbidden = array_merge($forbidden, $blacklist); } foreach ($overlay as $prop => $value) { if ($prop == 'start' || $prop == 'end') { // handled by merge_exception_dates() below } elseif ($prop == 'thisandfuture' && $overlay['_instance'] == $event['_instance']) { $event[$prop] = $value; } elseif ($prop[0] != '_' && !in_array($prop, $forbidden)) { $event[$prop] = $value; } } self::merge_exception_dates($event, $overlay); } /** * Merge start/end date from the overlay event to the base event object * * @param array $event The event object to be altered * @param array $overlay The overlay event object to be merged over $event */ public static function merge_exception_dates(&$event, $overlay) { // compute date offset from the exception if ($overlay['start'] instanceof DateTimeInterface && $overlay['recurrence_date'] instanceof DateTimeInterface) { $date_offset = $overlay['recurrence_date']->diff($overlay['start']); } foreach (['start', 'end'] as $prop) { $value = $overlay[$prop]; if (isset($event[$prop]) && ($event[$prop] instanceof DateTime || $event[$prop] instanceof DateTimeImmutable)) { // set date value if overlay is an exception of the current instance if (substr($overlay['_instance'], 0, 8) == substr($event['_instance'], 0, 8)) { $event[$prop]->setDate(intval($value->format('Y')), intval($value->format('n')), intval($value->format('j'))); } // apply date offset elseif (!empty($date_offset)) { $event[$prop]->add($date_offset); } // adjust time of the recurring event instance $event[$prop]->setTime($value->format('G'), intval($value->format('i')), intval($value->format('s'))); } } } /** * Get events from source. * * @param int $start Event's new start (unix timestamp) * @param int $end Event's new end (unix timestamp) * @param string $search Search query (optional) * @param mixed $calendars List of calendar IDs to load events from (either as array or comma-separated string) * @param bool $virtual Include virtual events (optional) * @param int $modifiedsince Only list events modified since this time (unix timestamp) * * @return array A list of event records */ public function load_events($start, $end, $search = null, $calendars = null, $virtual = true, $modifiedsince = null) { if ($calendars && is_string($calendars)) { $calendars = explode(',', $calendars); } elseif (!$calendars) { $this->_read_calendars(); $calendars = array_keys($this->calendars); } $query = []; $events = []; $categories = []; if ($modifiedsince) { $query[] = ['changed', '>=', $modifiedsince]; } foreach ($calendars as $cid) { if ($storage = $this->get_calendar($cid)) { $events = array_merge($events, $storage->list_events($start, $end, $search, $virtual, $query)); $categories += $storage->categories; } } // add events from the address books birthday calendar if (in_array(self::BIRTHDAY_CALENDAR_ID, $calendars)) { $events = array_merge($events, $this->load_birthday_events($start, $end, $search, $modifiedsince)); } // add new categories to user prefs $old_categories = $this->rc->config->get('calendar_categories', $this->default_categories); $newcats = array_udiff( array_keys($categories), array_keys($old_categories), function ($a, $b) { return strcasecmp($a, $b); } ); if (!empty($newcats)) { foreach ($newcats as $category) { $old_categories[$category] = ''; // no color set yet } $this->rc->user->save_prefs(['calendar_categories' => $old_categories]); } array_walk($events, 'kolab_driver::to_rcube_event'); return $events; } /** * Get number of events in the given calendar * * @param mixed $calendars List of calendar IDs to count events (either as array or comma-separated string) * @param int $start Date range start (unix timestamp) * @param ?int $end Date range end (unix timestamp) * * @return array Hash array with counts grouped by calendar ID */ public function count_events($calendars, $start, $end = null) { $counts = []; if ($calendars && is_string($calendars)) { $calendars = explode(',', $calendars); } elseif (!$calendars) { $this->_read_calendars(); $calendars = array_keys($this->calendars); } foreach ($calendars as $cid) { if ($storage = $this->get_calendar($cid)) { $counts[$cid] = $storage->count_events($start, $end); } } return $counts; } /** * Get a list of pending alarms to be displayed to the user * * @see calendar_driver::pending_alarms() */ public function pending_alarms($time, $calendars = null) { $interval = 300; $time -= $time % 60; $slot = $time; $slot -= $slot % $interval; $last = $time - max(60, $this->rc->config->get('refresh_interval', 0)); $last -= $last % $interval; // only check for alerts once in 5 minutes if ($last == $slot) { return []; } if ($calendars && is_string($calendars)) { $calendars = explode(',', $calendars); } $time = $slot + $interval; $alarms = []; $candidates = []; $query = [['tags', '=', 'x-has-alarms']]; $this->_read_calendars(); foreach ($this->calendars as $cid => $calendar) { // skip calendars with alarms disabled if (!$calendar->alarms || ($calendars && !in_array($cid, $calendars))) { continue; } foreach ($calendar->list_events($time, $time + 86400 * 365, null, 1, $query) as $e) { // add to list if alarm is set $alarm = libcalendaring::get_next_alarm($e); if ($alarm && !empty($alarm['time']) && $alarm['time'] >= $last && in_array($alarm['action'], $this->alarm_types) ) { $id = $alarm['id']; // use alarm-id as primary identifier $candidates[$id] = [ 'id' => $id, 'title' => $e['title'], 'location' => $e['location'] ?? null, 'start' => $e['start'], 'end' => $e['end'], 'notifyat' => $alarm['time'], 'action' => $alarm['action'], ]; } } } // get alarm information stored in local database if (!empty($candidates)) { $dbdata = []; $alarm_ids = array_map([$this->rc->db, 'quote'], array_keys($candidates)); $result = $this->rc->db->query( "SELECT *" . " FROM " . $this->rc->db->table_name('kolab_alarms', true) . " WHERE `alarm_id` IN (" . implode(',', $alarm_ids) . ")" . " AND `user_id` = ?", $this->rc->user->ID ); while ($result && ($e = $this->rc->db->fetch_assoc($result))) { $dbdata[$e['alarm_id']] = $e; } foreach ($candidates as $id => $alarm) { - if (!array_key_exists($id, $dbdata)) { - continue; - } - // skip dismissed alarms if (!empty($dbdata[$id]['dismissed'])) { continue; } // snooze function may have shifted alarm time $notifyat = !empty($dbdata[$id]['notifyat']) ? strtotime($dbdata[$id]['notifyat']) : $alarm['notifyat']; if ($notifyat <= $time) { $alarms[] = $alarm; } } } return $alarms; } /** * Feedback after showing/sending an alarm notification * * @see calendar_driver::dismiss_alarm() */ public function dismiss_alarm($alarm_id, $snooze = 0) { $alarms_table = $this->rc->db->table_name('kolab_alarms', true); // delete old alarm entry $this->rc->db->query( "DELETE FROM $alarms_table" . " WHERE `alarm_id` = ? AND `user_id` = ?", $alarm_id, $this->rc->user->ID ); // set new notifyat time or unset if not snoozed $notifyat = $snooze > 0 ? date('Y-m-d H:i:s', time() + $snooze) : null; $query = $this->rc->db->query( "INSERT INTO $alarms_table" . " (`alarm_id`, `user_id`, `dismissed`, `notifyat`)" . " VALUES (?, ?, ?, ?)", $alarm_id, $this->rc->user->ID, $snooze > 0 ? 0 : 1, $notifyat ); return $this->rc->db->affected_rows($query); } /** * List attachments from the given event */ public function list_attachments($event) { if (!($storage = $this->get_calendar($event['calendar']))) { return []; } $event = $storage->get_event($event['id']); return $event['attachments']; } /** * Get attachment properties */ public function get_attachment($id, $event) { if (!($storage = $this->get_calendar($event['calendar']))) { return false; } // get old revision of event if (!empty($event['rev'])) { $event = $this->get_event_revison($event, $event['rev'], true); } else { $event = $storage->get_event($event['id']); } if ($event) { $attachments = $event['_attachments'] ?? $event['attachments']; foreach ((array) $attachments as $idx => $att) { if ((isset($att['id']) && $att['id'] == $id) || (!isset($att['id']) && $idx == $id)) { return $att; } } } return false; } /** * Get attachment body * @see calendar_driver::get_attachment_body() */ public function get_attachment_body($id, $event) { if (!($cal = $this->get_calendar($event['calendar']))) { return false; } // get old revision of event if (!empty($event['rev'])) { if (empty($this->bonnie_api)) { return false; } $cid = substr($id, 4); // call Bonnie API and get the raw mime message [$uid, $mailbox, $msguid] = $this->_resolve_event_identity($event); if ($msg_raw = $this->bonnie_api->rawdata('event', $uid, $event['rev'], $mailbox, $msguid)) { // parse the message and find the part with the matching content-id $message = rcube_mime::parse_message($msg_raw); foreach ((array) $message->parts as $part) { if (!empty($part->headers['content-id']) && trim($part->headers['content-id'], '<>') == $cid) { return $part->body; } } } return false; } return $cal->get_attachment_body($id, $event); } /** * Build a struct representing the given message reference * * @see calendar_driver::get_message_reference() */ public function get_message_reference($uri_or_headers, $folder = null) { if (is_object($uri_or_headers)) { $uri_or_headers = kolab_storage_config::get_message_uri($uri_or_headers, $folder); } if (is_string($uri_or_headers)) { return kolab_storage_config::get_message_reference($uri_or_headers, 'event'); } return false; } /** * List availabale categories * The default implementation reads them from config/user prefs */ public function list_categories() { // FIXME: complete list with categories saved in config objects (KEP:12) return $this->rc->config->get('calendar_categories', $this->default_categories); } /** * Create instances of a recurring event * * @param array $event Hash array with event properties * @param DateTime $start Start date of the recurrence window * @param DateTime $end End date of the recurrence window * * @return array List of recurring event instances */ public function get_recurring_events($event, $start, $end = null) { // load the given event data into a libkolabxml container if (empty($event['_formatobj'])) { $event_xml = new kolab_format_event(); $event_xml->set($event); $event['_formatobj'] = $event_xml; } $this->_read_calendars(); $storage = reset($this->calendars); return $storage->get_recurring_events($event, $start, $end); } /** * */ protected function get_recurrence_count($event, $dtstart) { // load the given event data into a libkolabxml container if (empty($event['_formatobj'])) { $event_xml = new kolab_format_event(); $event_xml->set($event); $event['_formatobj'] = $event_xml; } // use libkolab to compute recurring events $recurrence = new kolab_date_recurrence($event['_formatobj']); $count = 0; while (($next_event = $recurrence->next_instance()) && $next_event['start'] <= $dtstart && $count < 1000) { $count++; } return $count; } /** * Fetch free/busy information from a person within the given range */ public function get_freebusy_list($email, $start, $end) { if (empty($email)/* || $end < time()*/) { return false; } $url = $this->storage->get_freebusy_url($email); if (empty($url)) { return false; } // map vcalendar fbtypes to internal values $fbtypemap = [ 'FREE' => calendar::FREEBUSY_FREE, 'BUSY-TENTATIVE' => calendar::FREEBUSY_TENTATIVE, 'X-OUT-OF-OFFICE' => calendar::FREEBUSY_OOF, 'OOF' => calendar::FREEBUSY_OOF, ]; // ask kolab server first try { $request_config = [ 'store_body' => true, 'follow_redirects' => true, ]; $request = libkolab::http_request($url, 'GET', $request_config); $response = $request->send(); // authentication required if ($response->getStatus() == 401) { $request->setAuth($this->rc->user->get_username(), $this->rc->decrypt($_SESSION['password'])); $response = $request->send(); } if ($response->getStatus() == 200) { $fbdata = $response->getBody(); } unset($request, $response); } catch (Exception $e) { rcube::raise_error($e); return false; } // get free-busy url from contacts if (empty($fbdata)) { $fburl = null; foreach ((array) $this->rc->config->get('autocomplete_addressbooks', 'sql') as $book) { $abook = $this->rc->get_address_book($book); if ($result = $abook->search(['email'], $email, true, true, true/*, 'freebusyurl'*/)) { while ($contact = $result->iterate()) { if (!empty($contact['freebusyurl'])) { $fbdata = @file_get_contents($contact['freebusyurl']); break; } } } if (!empty($fbdata)) { break; } } } // parse free-busy information using Horde classes if (!empty($fbdata)) { $ical = $this->cal->get_ical(); $ical->import($fbdata); if ($fb = $ical->freebusy) { $result = []; foreach ($fb['periods'] as $tuple) { [$from, $to, $type] = $tuple; $result[] = [ $from->format('U'), $to->format('U'), $fbtypemap[$type] ?? calendar::FREEBUSY_BUSY, ]; } // we take 'dummy' free-busy lists as "unknown" if (empty($result) && !empty($fb['comment']) && stripos($fb['comment'], 'dummy')) { return false; } // set period from $start till the begin of the free-busy information as 'unknown' if (!empty($fb['start']) && ($fbstart = $fb['start']->format('U')) && $start < $fbstart) { array_unshift($result, [$start, $fbstart, calendar::FREEBUSY_UNKNOWN]); } // pad period till $end with status 'unknown' if (!empty($fb['end']) && ($fbend = $fb['end']->format('U')) && $fbend < $end) { $result[] = [$fbend, $end, calendar::FREEBUSY_UNKNOWN]; } return $result; } } return false; } /** * Handler to push folder triggers when sent from client. * Used to push free-busy changes asynchronously after updating an event */ public function push_freebusy() { // make shure triggering completes set_time_limit(0); ignore_user_abort(true); $cal = rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC); if (!($cal = $this->get_calendar($cal))) { return false; } // trigger updates on folder $trigger = $cal->storage->trigger(); if (is_object($trigger) && is_a($trigger, 'PEAR_Error')) { rcube::raise_error( [ 'code' => 900, 'file' => __FILE__, 'line' => __LINE__, 'message' => "Failed triggering folder. Error was " . $trigger->getMessage(), ], true, false ); } exit; } /** * Convert from driver format to external caledar app data */ public static function to_rcube_event(&$record) { if (!is_array($record)) { return $record; } $record['id'] = $record['uid'] ?? null; if (!empty($record['_instance'])) { $record['id'] .= '-' . $record['_instance']; if (empty($record['recurrence_id']) && !empty($record['recurrence'])) { $record['recurrence_id'] = $record['uid']; } } // all-day events go from 12:00 - 13:00 if (($record['start'] instanceof DateTime || $record['start'] instanceof DateTimeImmutable) && $record['end'] <= $record['start'] && !empty($record['allday']) ) { $record['end'] = clone $record['start']; $record['end']->add(new DateInterval('PT1H')); } // translate internal '_attachments' to external 'attachments' list if (!empty($record['_attachments'])) { $attachments = []; foreach ($record['_attachments'] as $key => $attachment) { if ($attachment !== false) { if (empty($attachment['name'])) { $attachment['name'] = $key; } unset($attachment['path'], $attachment['content']); $attachments[] = $attachment; } } $record['attachments'] = $attachments; } if (!empty($record['attendees'])) { foreach ((array) $record['attendees'] as $i => $attendee) { if (isset($attendee['delegated-from']) && is_array($attendee['delegated-from'])) { $record['attendees'][$i]['delegated-from'] = implode(', ', $attendee['delegated-from']); } if (isset($attendee['delegated-to']) && is_array($attendee['delegated-to'])) { $record['attendees'][$i]['delegated-to'] = implode(', ', $attendee['delegated-to']); } } } // Roundcube only supports one category assignment if (!empty($record['categories']) && is_array($record['categories'])) { $record['categories'] = $record['categories'][0]; } // the cancelled flag transltes into status=CANCELLED if (!empty($record['cancelled'])) { $record['status'] = 'CANCELLED'; } // The web client only supports DISPLAY type of alarms if (!empty($record['alarms'])) { $record['alarms'] = preg_replace('/:[A-Z]+$/', ':DISPLAY', $record['alarms']); } // remove empty recurrence array if (empty($record['recurrence'])) { unset($record['recurrence']); } // clean up exception data elseif (!empty($record['recurrence']['EXCEPTIONS'])) { array_walk($record['recurrence']['EXCEPTIONS'], function (&$exception) { unset($exception['_mailbox'], $exception['_msguid'], $exception['_formatobj'], $exception['_attachments'] ); }); } unset($record['_mailbox'], $record['_msguid'], $record['_type'], $record['_size'], $record['_formatobj'], $record['_attachments'], $record['exceptions'], $record['x-custom'] ); return $record; } /** * */ public static function from_rcube_event($event, $old = []) { kolab_format::merge_attachments($event, $old); return $event; } /** * Set CSS class according to the event's attendde partstat */ public static function add_partstat_class($event, $partstats, $user = null) { // set classes according to PARTSTAT if (!empty($event['attendees'])) { $user_emails = libcalendaring::get_instance()->get_user_emails($user); $partstat = 'UNKNOWN'; foreach ($event['attendees'] as $attendee) { if (in_array($attendee['email'], $user_emails)) { if (!empty($attendee['status'])) { $partstat = $attendee['status']; } break; } } if (in_array($partstat, $partstats)) { $event['className'] = trim(($event['className'] ?? '') . ' fc-invitation-' . strtolower($partstat)); } } return $event; } /** * Provide a list of revisions for the given event * * @param array $event Hash array with event properties * * @return array|false List of changes, each as a hash array * @see calendar_driver::get_event_changelog() */ public function get_event_changelog($event) { if (empty($this->bonnie_api)) { return false; } [$uid, $mailbox, $msguid] = $this->_resolve_event_identity($event); $result = $this->bonnie_api->changelog('event', $uid, $mailbox, $msguid); if (is_array($result) && $result['uid'] == $uid) { return $result['changes']; } return false; } /** * Get a list of property changes beteen two revisions of an event * * @param array $event Hash array with event properties * @param mixed $rev1 Old Revision * @param mixed $rev2 New Revision * * @return array|false List of property changes, each as a hash array * @see calendar_driver::get_event_diff() */ public function get_event_diff($event, $rev1, $rev2) { if (empty($this->bonnie_api)) { return false; } [$uid, $mailbox, $msguid] = $this->_resolve_event_identity($event); // get diff for the requested recurrence instance $instance_id = $event['id'] != $uid ? substr($event['id'], strlen($uid) + 1) : null; // call Bonnie API $result = $this->bonnie_api->diff('event', $uid, $rev1, $rev2, $mailbox, $msguid, $instance_id); if (is_array($result) && $result['uid'] == $uid) { $result['rev1'] = $rev1; $result['rev2'] = $rev2; $keymap = [ 'dtstart' => 'start', 'dtend' => 'end', 'dstamp' => 'changed', 'summary' => 'title', 'alarm' => 'alarms', 'attendee' => 'attendees', 'attach' => 'attachments', 'rrule' => 'recurrence', 'transparency' => 'free_busy', 'lastmodified-date' => 'changed', ]; $prop_keymaps = [ 'attachments' => ['fmttype' => 'mimetype', 'label' => 'name'], 'attendees' => ['partstat' => 'status'], ]; $special_changes = []; // map kolab event properties to keys the client expects array_walk($result['changes'], function (&$change, $i) use ($keymap, $prop_keymaps, &$special_changes) { if (array_key_exists($change['property'], $keymap)) { $change['property'] = $keymap[$change['property']]; } // translate free_busy values if ($change['property'] == 'free_busy') { $change['old'] = !empty($change['old']) ? 'free' : 'busy'; $change['new'] = !empty($change['new']) ? 'free' : 'busy'; } // map alarms trigger value if ($change['property'] == 'alarms') { if (!empty($change['old']['trigger'])) { $change['old']['trigger'] = $change['old']['trigger']['value']; } if (!empty($change['new']['trigger'])) { $change['new']['trigger'] = $change['new']['trigger']['value']; } } // make all property keys uppercase if ($change['property'] == 'recurrence') { $special_changes['recurrence'] = $i; foreach (['old', 'new'] as $m) { if (!empty($change[$m])) { $props = []; foreach ($change[$m] as $k => $v) { $props[strtoupper($k)] = $v; } $change[$m] = $props; } } } // map property keys names if (!empty($prop_keymaps[$change['property']])) { foreach ($prop_keymaps[$change['property']] as $k => $dest) { if (!empty($change['old']) && array_key_exists($k, $change['old'])) { $change['old'][$dest] = $change['old'][$k]; unset($change['old'][$k]); } if (!empty($change['new']) && array_key_exists($k, $change['new'])) { $change['new'][$dest] = $change['new'][$k]; unset($change['new'][$k]); } } } if ($change['property'] == 'exdate') { $special_changes['exdate'] = $i; } elseif ($change['property'] == 'rdate') { $special_changes['rdate'] = $i; } }); // merge some recurrence changes foreach (['exdate', 'rdate'] as $prop) { if (array_key_exists($prop, $special_changes)) { $exdate = $result['changes'][$special_changes[$prop]]; if (array_key_exists('recurrence', $special_changes)) { $recurrence = &$result['changes'][$special_changes['recurrence']]; } else { $i = count($result['changes']); $result['changes'][$i] = ['property' => 'recurrence', 'old' => [], 'new' => []]; $recurrence = &$result['changes'][$i]['recurrence']; } $key = strtoupper($prop); $recurrence['old'][$key] = $exdate['old']; $recurrence['new'][$key] = $exdate['new']; unset($result['changes'][$special_changes[$prop]]); } } return $result; } return false; } /** * Return full data of a specific revision of an event * * @param array $event Hash array with event properties * @param mixed $rev Revision number * * @return array|false Event object as hash array * @see calendar_driver::get_event_revison() */ public function get_event_revison($event, $rev, $internal = false) { if (empty($this->bonnie_api)) { return false; } $eventid = $event['id']; $calid = $event['calendar']; [$uid, $mailbox, $msguid] = $this->_resolve_event_identity($event); // call Bonnie API $result = $this->bonnie_api->get('event', $uid, $rev, $mailbox, $msguid); if (is_array($result) && $result['uid'] == $uid && !empty($result['xml'])) { /** @var kolab_format_event $format */ $format = kolab_format::factory('event'); $format->load($result['xml']); $event = $format->to_array(); $format->get_attachments($event, true); // get the right instance from a recurring event if ($eventid != $event['uid']) { $instance_id = substr($eventid, strlen($event['uid']) + 1); // check for recurrence exception first if ($instance = $format->get_instance($instance_id)) { $event = $instance; } else { // not a exception, compute recurrence... $event['_formatobj'] = $format; $recurrence_date = rcube_utils::anytodatetime($instance_id, $event['start']->getTimezone()); foreach ($this->get_recurring_events($event, $event['start'], $recurrence_date) as $instance) { if ($instance['id'] == $eventid) { $event = $instance; break; } } } } if ($format->is_valid()) { $event['calendar'] = $calid; $event['rev'] = $result['rev']; return $internal ? $event : self::to_rcube_event($event); } } return false; } /** * Command the backend to restore a certain revision of an event. * This shall replace the current event with an older version. * * @param mixed $event UID string or hash array with event properties: * id: Event identifier * calendar: Calendar identifier * @param mixed $rev Revision number * * @return bool True on success, False on failure */ public function restore_event_revision($event, $rev) { if (empty($this->bonnie_api)) { return false; } [$uid, $mailbox, $msguid] = $this->_resolve_event_identity($event); $calendar = $this->get_calendar($event['calendar']); $success = false; if ($calendar && $calendar->storage && $calendar->editable) { if ($raw_msg = $this->bonnie_api->rawdata('event', $uid, $rev, $mailbox)) { $imap = $this->rc->get_storage(); // insert $raw_msg as new message if ($imap->save_message($calendar->storage->name, $raw_msg, null, false)) { $success = true; // delete old revision from imap and cache $imap->delete_message($msguid, $calendar->storage->name); $calendar->storage->cache->set($msguid, false); } } } return $success; } /** * Helper method to resolved the given event identifier into uid and folder * * @return array (uid,folder,msguid) tuple */ protected function _resolve_event_identity($event) { $mailbox = $msguid = null; if (is_array($event)) { $uid = !empty($event['uid']) ? $event['uid'] : $event['id']; if (($cal = $this->get_calendar($event['calendar'])) && !($cal instanceof kolab_invitation_calendar)) { $mailbox = $cal->get_mailbox_id(); // get event object from storage in order to get the real object uid an msguid if ($ev = $cal->get_event($event['id'])) { $msguid = $ev['_msguid']; $uid = $ev['uid']; } } } else { $uid = $event; // get event object from storage in order to get the real object uid an msguid if ($ev = $this->get_event($event)) { $mailbox = $ev['_mailbox']; $msguid = $ev['_msguid']; $uid = $ev['uid']; } } return [$uid, $mailbox, $msguid]; } /** * Callback function to produce driver-specific calendar create/edit form * * @param string $action Request action 'form-edit|form-new' * @param array $calendar Calendar properties (e.g. id, color) * @param array $formfields Edit form fields * * @return string HTML content of the form */ public function calendar_form($action, $calendar, $formfields) { $special_calendars = [ self::BIRTHDAY_CALENDAR_ID, self::INVITATIONS_CALENDAR_PENDING, self::INVITATIONS_CALENDAR_DECLINED, ]; // show default dialog for birthday calendar if (in_array($calendar['id'], $special_calendars)) { if ($calendar['id'] != self::BIRTHDAY_CALENDAR_ID) { unset($formfields['showalarms']); } // General tab $form['props'] = [ 'name' => $this->rc->gettext('properties'), 'fields' => $formfields, ]; return kolab_utils::folder_form($form, '', 'calendar'); } $this->_read_calendars(); if (!empty($calendar['id']) && ($cal = $this->calendars[$calendar['id']])) { $folder = $cal->get_realname(); // UTF7 $color = $cal->get_color(); } else { $folder = ''; $color = ''; } $hidden_fields[] = ['name' => 'oldname', 'value' => $folder]; $storage = $this->rc->get_storage(); $delim = $storage->get_hierarchy_delimiter(); $form = []; if (strlen($folder)) { $path_imap = explode($delim, $folder); array_pop($path_imap); // pop off name part $path_imap = implode($delim, $path_imap); $options = $storage->folder_info($folder); } else { $path_imap = ''; } // General tab $form['props'] = [ 'name' => $this->rc->gettext('properties'), 'fields' => [], ]; $protected = !empty($options) && (!empty($options['norename']) || !empty($options['protected'])); // Disable folder name input if ($protected) { $input_name = new html_hiddenfield(['name' => 'name', 'id' => 'calendar-name', 'value' => $folder]); $formfields['name']['value'] = $this->storage->object_name($folder) . $input_name->show(); } // calendar name (default field) $form['props']['fields']['location'] = $formfields['name']; if ($protected) { // prevent user from moving folder $hidden_fields[] = ['name' => 'parent', 'value' => $path_imap]; } else { $select = $this->storage->folder_selector('event', ['name' => 'parent', 'id' => 'calendar-parent'], $folder); $form['props']['fields']['path'] = [ 'id' => 'calendar-parent', 'label' => $this->cal->gettext('parentcalendar'), 'value' => $select->show(strlen($folder) ? $path_imap : ''), ]; } // calendar color (default field) $form['props']['fields']['color'] = $formfields['color']; $form['props']['fields']['alarms'] = $formfields['showalarms']; return kolab_utils::folder_form($form, $folder, 'calendar', $hidden_fields); } /** * Handler for user_delete plugin hook */ public function user_delete($args) { $db = $this->rc->get_dbh(); foreach (['kolab_alarms', 'itipinvitations'] as $table) { $db->query("DELETE FROM " . $this->rc->db->table_name($table, true) . " WHERE `user_id` = ?", $args['user']->ID); } return $args; } } diff --git a/plugins/libcalendaring/libcalendaring.php b/plugins/libcalendaring/libcalendaring.php index f4166b69..65b37679 100644 --- a/plugins/libcalendaring/libcalendaring.php +++ b/plugins/libcalendaring/libcalendaring.php @@ -1,1654 +1,1652 @@ * * Copyright (C) 2012-2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class libcalendaring extends rcube_plugin { public $rc; public $timezone; public $gmt_offset; public $dst_active; public $timezone_offset; public $ical_parts = []; /** @var ?rcube_message Email message */ public $ical_message; /** @var array Configuration defaults */ public $defaults = [ 'calendar_date_format' => 'Y-m-d', 'calendar_date_short' => 'M-j', 'calendar_date_long' => 'F j Y', 'calendar_date_agenda' => 'l M-d', 'calendar_time_format' => 'H:m', 'calendar_first_day' => 1, 'calendar_first_hour' => 6, 'calendar_date_format_sets' => [ 'Y-m-d' => ['d M Y', 'm-d', 'l m-d'], 'Y/m/d' => ['d M Y', 'm/d', 'l m/d'], 'Y.m.d' => ['d M Y', 'm.d', 'l m.d'], 'd-m-Y' => ['d M Y', 'd-m', 'l d-m'], 'd/m/Y' => ['d M Y', 'd/m', 'l d/m'], 'd.m.Y' => ['d M Y', 'd.m', 'l d.m'], 'j.n.Y' => ['d M Y', 'd.m', 'l d.m'], 'm/d/Y' => ['M d Y', 'm/d', 'l m/d'], ], ]; private static $instance; private $mail_ical_parser; /** * Singleton getter to allow direct access from other plugins */ public static function get_instance() { if (!self::$instance) { self::$instance = new libcalendaring(rcube::get_instance()->plugins); self::$instance->init_instance(); } return self::$instance; } /** * Initializes class properties */ public function init_instance() { $this->rc = rcube::get_instance(); // set user's timezone try { $this->timezone = new DateTimeZone($this->rc->config->get('timezone', 'GMT')); } catch (Exception $e) { $this->timezone = new DateTimeZone('GMT'); } $now = new DateTime('now', $this->timezone); $this->gmt_offset = $now->getOffset(); $this->dst_active = $now->format('I'); $this->timezone_offset = $this->gmt_offset / 3600 - $this->dst_active; $this->add_texts('localization/', false); } /** * Required plugin startup method */ public function init() { // extend include path to load bundled lib classes $include_path = $this->home . '/lib' . PATH_SEPARATOR . ini_get('include_path'); set_include_path($include_path); self::$instance = $this; $this->rc = rcube::get_instance(); $this->init_instance(); // include client scripts and styles if ($this->rc->output) { // add hook to display alarms $this->add_hook('refresh', [$this, 'refresh']); $this->register_action('plugin.alarms', [$this, 'alarms_action']); $this->register_action('plugin.expand_attendee_group', [$this, 'expand_attendee_group']); } // proceed initialization in startup hook $this->add_hook('startup', [$this, 'startup']); } /** * Startup hook */ public function startup($args) { if ($this->rc->output && $this->rc->output->type == 'html') { $this->rc->output->set_env('libcal_settings', $this->load_settings()); $this->include_script('libcalendaring.js'); $this->include_stylesheet($this->local_skin_path() . '/libcal.css'); $this->add_label( 'itipaccepted', 'itiptentative', 'itipdeclined', 'itipdelegated', 'expandattendeegroup', 'expandattendeegroupnodata', 'statusorganizer', 'statusaccepted', 'statusdeclined', 'statusdelegated', 'statusunknown', 'statusneeds-action', 'statustentative', 'statuscompleted', 'statusin-process', 'delegatedto', 'delegatedfrom', 'showmore', 'savein' ); } if (($args['task'] ?? null) == 'mail') { if ($args['action'] == 'show' || $args['action'] == 'preview') { $this->add_hook('message_load', [$this, 'mail_message_load']); } } } /** * Load iCalendar functions * * @return libcalendaring_vcalendar iCal parser */ public static function get_ical() { $self = self::get_instance(); return new libcalendaring_vcalendar(); } /** * Load iTip functions */ public static function get_itip($domain = 'libcalendaring') { $self = self::get_instance(); return new libcalendaring_itip($self, $domain); } /** * Load recurrence computation engine */ public static function get_recurrence($object = null) { $self = self::get_instance(); return new libcalendaring_recurrence($self, $object); } /** * Shift dates into user's current timezone. * * @param mixed $dt Any kind of a date representation (DateTime object, string or unix timestamp) * * @return DateTime DateTime object in user's timezone */ public function adjust_timezone($dt, $dateonly = false) { if (is_numeric($dt)) { $dt = new DateTime('@' . $dt); } elseif (is_string($dt)) { $dt = rcube_utils::anytodatetime($dt); } if ($dt instanceof DateTime && empty($dt->_dateonly) && !$dateonly) { $dt = $dt->setTimezone($this->timezone); } return $dt; } /** * */ public function load_settings() { $this->date_format_defaults(); $settings = []; $keys = ['date_format', 'time_format', 'date_short', 'date_long', 'date_agenda']; foreach ($keys as $key) { $settings[$key] = (string)$this->rc->config->get('calendar_' . $key, $this->defaults['calendar_' . $key]); $settings[$key] = self::from_php_date_format($settings[$key]); } $settings['dates_long'] = $settings['date_long']; $settings['first_day'] = (int)$this->rc->config->get('calendar_first_day', $this->defaults['calendar_first_day']); $settings['timezone'] = $this->timezone_offset; $settings['dst'] = $this->dst_active; // localization $settings['days'] = [ $this->rc->gettext('sunday'), $this->rc->gettext('monday'), $this->rc->gettext('tuesday'), $this->rc->gettext('wednesday'), $this->rc->gettext('thursday'), $this->rc->gettext('friday'), $this->rc->gettext('saturday'), ]; $settings['days_short'] = [ $this->rc->gettext('sun'), $this->rc->gettext('mon'), $this->rc->gettext('tue'), $this->rc->gettext('wed'), $this->rc->gettext('thu'), $this->rc->gettext('fri'), $this->rc->gettext('sat'), ]; $settings['months'] = [ $this->rc->gettext('longjan'), $this->rc->gettext('longfeb'), $this->rc->gettext('longmar'), $this->rc->gettext('longapr'), $this->rc->gettext('longmay'), $this->rc->gettext('longjun'), $this->rc->gettext('longjul'), $this->rc->gettext('longaug'), $this->rc->gettext('longsep'), $this->rc->gettext('longoct'), $this->rc->gettext('longnov'), $this->rc->gettext('longdec'), ]; $settings['months_short'] = [ $this->rc->gettext('jan'), $this->rc->gettext('feb'), $this->rc->gettext('mar'), $this->rc->gettext('apr'), $this->rc->gettext('may'), $this->rc->gettext('jun'), $this->rc->gettext('jul'), $this->rc->gettext('aug'), $this->rc->gettext('sep'), $this->rc->gettext('oct'), $this->rc->gettext('nov'), $this->rc->gettext('dec'), ]; $settings['today'] = $this->rc->gettext('today'); return $settings; } /** * Helper function to set date/time format according to config and user preferences */ private function date_format_defaults() { static $defaults = []; // nothing to be done if (isset($defaults['date_format'])) { return; } $defaults['date_format'] = $this->rc->config->get('calendar_date_format', $this->rc->config->get('date_format')); $defaults['time_format'] = $this->rc->config->get('calendar_time_format', $this->rc->config->get('time_format')); // override defaults if ($defaults['date_format']) { $this->defaults['calendar_date_format'] = $defaults['date_format']; } if ($defaults['time_format']) { $this->defaults['calendar_time_format'] = $defaults['time_format']; } // derive format variants from basic date format $format_sets = $this->rc->config->get('calendar_date_format_sets', $this->defaults['calendar_date_format_sets']); if ($format_set = $format_sets[$this->defaults['calendar_date_format']]) { $this->defaults['calendar_date_long'] = $format_set[0]; $this->defaults['calendar_date_short'] = $format_set[1]; $this->defaults['calendar_date_agenda'] = $format_set[2]; } } /** * Compose a date string for the given event */ public function event_date_text($event) { $fromto = '--'; $is_task = !empty($event['_type']) && $event['_type'] == 'task'; $this->date_format_defaults(); $date_format = self::to_php_date_format($this->rc->config->get('calendar_date_format', $this->defaults['calendar_date_format'])); $time_format = self::to_php_date_format($this->rc->config->get('calendar_time_format', $this->defaults['calendar_time_format'])); $getTimezone = function ($date) { if ($newTz = $date->getTimezone()) { return $newTz->getName(); } return ''; }; $formatDate = function ($date, $format) use ($getTimezone) { // This is a workaround for the rcmail::format_date() which does not play nice with timezone $tz = $this->rc->config->get('timezone'); if ($dateTz = $getTimezone($date)) { $this->rc->config->set('timezone', $dateTz); } $result = $this->rc->format_date($date, $format); $this->rc->config->set('timezone', $tz); return $result; }; // handle task objects if ($is_task && !empty($event['due']) && is_object($event['due'])) { $fromto = $formatDate($event['due'], !empty($event['due']->_dateonly) ? $date_format : null); // add timezone information if ($fromto && empty($event['due']->_dateonly) && ($tz = $getTimezone($event['due']))) { $fromto .= ' (' . strtr($tz, '_', ' ') . ')'; } return $fromto; } // abort if no valid event dates are given if (!is_object($event['start']) || !is_a($event['start'], 'DateTime') || !is_object($event['end']) || !is_a($event['end'], 'DateTime')) { return $fromto; } if ($event['allday']) { $fromto = $formatDate($event['start'], $date_format); if (($todate = $formatDate($event['end'], $date_format)) != $fromto) { $fromto .= ' - ' . $todate; } } elseif ($event['start']->format('Ymd') === $event['end']->format('Ymd')) { $fromto = $formatDate($event['start'], $date_format) . ' ' . $formatDate($event['start'], $time_format) . ' - ' . $formatDate($event['end'], $time_format); } else { $fromto = $formatDate($event['start'], $date_format) . ' ' . $formatDate($event['start'], $time_format) . ' - ' . $formatDate($event['end'], $date_format) . ' ' . $formatDate($event['end'], $time_format); } // add timezone information if ($fromto && empty($event['allday']) && ($tz = $getTimezone($event['start']))) { $fromto .= ' (' . strtr($tz, '_', ' ') . ')'; } return $fromto; } /** * Render HTML form for alarm configuration */ public function alarm_select($attrib, $alarm_types, $absolute_time = true) { unset($attrib['name']); $input_value = new html_inputfield(['name' => 'alarmvalue[]', 'class' => 'edit-alarm-value form-control', 'size' => 3]); $input_date = new html_inputfield(['name' => 'alarmdate[]', 'class' => 'edit-alarm-date form-control', 'size' => 10]); $input_time = new html_inputfield(['name' => 'alarmtime[]', 'class' => 'edit-alarm-time form-control', 'size' => 6]); $select_type = new html_select(['name' => 'alarmtype[]', 'class' => 'edit-alarm-type form-control', 'id' => $attrib['id']]); $select_offset = new html_select(['name' => 'alarmoffset[]', 'class' => 'edit-alarm-offset form-control']); $select_related = new html_select(['name' => 'alarmrelated[]', 'class' => 'edit-alarm-related form-control']); $object_type = !empty($attrib['_type']) ? $attrib['_type'] : 'event'; $select_type->add($this->gettext('none'), ''); foreach ($alarm_types as $type) { $select_type->add($this->gettext(strtolower("alarm{$type}option")), $type); } foreach (['-M','-H','-D','+M','+H','+D'] as $trigger) { $select_offset->add($this->gettext('trigger' . $trigger), $trigger); } $select_offset->add($this->gettext('trigger0'), '0'); if ($absolute_time) { $select_offset->add($this->gettext('trigger@'), '@'); } $select_related->add($this->gettext('relatedstart'), 'start'); $select_related->add($this->gettext('relatedend' . $object_type), 'end'); // pre-set with default values from user settings $preset = self::parse_alarm_value($this->rc->config->get('calendar_default_alarm_offset', '-15M')); $hidden = ['style' => 'display:none']; return html::span( 'edit-alarm-set', $select_type->show($this->rc->config->get('calendar_default_alarm_type', '')) . ' ' . html::span( ['class' => 'edit-alarm-values input-group', 'style' => 'display:none'], $input_value->show($preset[0]) . ' ' . $select_offset->show($preset[1]) . ' ' . $select_related->show() . ' ' . $input_date->show('', $hidden) . ' ' . $input_time->show('', $hidden) ) ); } /** * Get a list of email addresses of the given user (from login and identities) * * @param string $user User Email (default to current user) * * @return array Email addresses related to the user */ public function get_user_emails($user = null) { static $_emails = []; if (empty($user)) { $user = $this->rc->user->get_username(); } // return cached result if (isset($_emails[$user])) { return $_emails[$user]; } $emails = [$user]; $plugin = $this->rc->plugins->exec_hook('calendar_user_emails', ['emails' => $emails]); $emails = array_map('strtolower', $plugin['emails']); // add all emails from the current user's identities if (!$plugin['abort'] && ($user == $this->rc->user->get_username())) { foreach ($this->rc->user->list_emails() as $identity) { $emails[] = strtolower($identity['email']); } } $_emails[$user] = array_unique($emails); return $_emails[$user]; } /** * Set the given participant status to the attendee matching the current user's identities * Unsets 'rsvp' flag too. * * @param array &$event Event data * @param string $status The PARTSTAT value to set * @param bool $recursive Recurive call * * @return string|false Email address of the updated attendee or False if none matching found */ public function set_partstat(&$event, $status, $recursive = true) { $success = false; $emails = $this->get_user_emails(); foreach ((array)$event['attendees'] as $i => $attendee) { if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { $event['attendees'][$i]['status'] = strtoupper($status); unset($event['attendees'][$i]['rsvp']); $success = $attendee['email']; } } // apply partstat update to each existing exception if ($event['recurrence'] && is_array($event['recurrence']['EXCEPTIONS'])) { foreach ($event['recurrence']['EXCEPTIONS'] as $i => $exception) { $this->set_partstat($event['recurrence']['EXCEPTIONS'][$i], $status, false); } // set link to top-level exceptions $event['exceptions'] = &$event['recurrence']['EXCEPTIONS']; } return $success; } /********* Alarms handling *********/ /** * Helper function to convert alarm trigger strings * into two-field values (e.g. "-45M" => 45, "-M") */ public static function parse_alarm_value($val) { if ($val[0] == '@') { return [new DateTime($val)]; } if (preg_match('/([+-]?)P?(T?\d+[HMSDW])+/', $val, $m) && preg_match_all('/T?(\d+)([HMSDW])/', $val, $m2, PREG_SET_ORDER)) { if ($m[1] == '') { $m[1] = '+'; } $prefix = ''; foreach ($m2 as $seg) { $prefix = $seg[2] == 'D' || $seg[2] == 'W' ? 'P' : 'PT'; if ($seg[1] > 0) { // ignore zero values // convert seconds to minutes if ($seg[2] == 'S') { $seg[2] = 'M'; $seg[1] = max(1, round(intval($seg[1]) / 60)); } return [$seg[1], $m[1] . $seg[2], $m[1] . $seg[1] . $seg[2], $m[1] . $prefix . $seg[1] . $seg[2]]; } } // return zero value nevertheless return [ $seg[1] ?? null, $m[1] . ($seg[2] ?? ''), $m[1] . ($seg[1] ?? '') . ($seg[2] ?? ''), $m[1] . $prefix . ($seg[1] ?? '') . ($seg[2] ?? ''), ]; } return false; } /** * Convert the alarms list items to be processed on the client */ public static function to_client_alarms($valarms) { return array_map(function ($alarm) { if ($alarm['trigger'] instanceof DateTimeInterface) { $alarm['trigger'] = '@' . $alarm['trigger']->format('U'); } elseif ($trigger = libcalendaring::parse_alarm_value($alarm['trigger'])) { $alarm['trigger'] = $trigger[2]; } return $alarm; }, (array)$valarms); } /** * Process the alarms values submitted by the client */ public static function from_client_alarms($valarms) { return array_map(function ($alarm) { if ($alarm['trigger'][0] == '@') { try { $alarm['trigger'] = new DateTime($alarm['trigger']); $alarm['trigger']->setTimezone(new DateTimeZone('UTC')); } catch (Exception $e) { /* handle this ? */ } } elseif ($trigger = libcalendaring::parse_alarm_value($alarm['trigger'])) { $alarm['trigger'] = $trigger[3]; } return $alarm; }, (array)$valarms); } /** * Render localized text for alarm settings */ public static function alarms_text($alarms) { if (is_array($alarms) && is_array($alarms[0])) { $texts = []; foreach ($alarms as $alarm) { if ($text = self::alarm_text($alarm)) { $texts[] = $text; } } return implode(', ', $texts); } else { return self::alarm_text($alarms); } } /** * Render localized text for a single alarm property */ public static function alarm_text($alarm) { $related = null; if (is_string($alarm)) { [$trigger, $action] = explode(':', $alarm); } else { $trigger = $alarm['trigger']; $action = $alarm['action']; if (!empty($alarm['related'])) { $related = $alarm['related']; } } $text = ''; $rcube = rcmail::get_instance(); switch ($action) { case 'EMAIL': $text = $rcube->gettext('libcalendaring.alarmemail'); break; case 'DISPLAY': $text = $rcube->gettext('libcalendaring.alarmdisplay'); break; case 'AUDIO': $text = $rcube->gettext('libcalendaring.alarmaudio'); break; } if ($trigger instanceof DateTimeInterface) { $text .= ' ' . $rcube->gettext([ 'name' => 'libcalendaring.alarmat', 'vars' => ['datetime' => $rcube->format_date($trigger)], ]); } elseif (preg_match('/@(\d+)/', $trigger, $m)) { $text .= ' ' . $rcube->gettext([ 'name' => 'libcalendaring.alarmat', 'vars' => ['datetime' => $rcube->format_date($m[1])], ]); } elseif ($val = self::parse_alarm_value($trigger)) { $r = $related && strtoupper($related) == 'END' ? 'end' : ''; // TODO: for all-day events say 'on date of event at XX' ? if ($val[0] == 0) { $text .= ' ' . $rcube->gettext('libcalendaring.triggerattime' . $r); } else { $label = 'libcalendaring.trigger' . $r . $val[1]; $text .= ' ' . intval($val[0]) . ' ' . $rcube->gettext($label); } } else { return false; } return $text; } /** * Get the next alarm (time & action) for the given event * * @param array $rec Record data * * @return array|null Hash array with alarm time/type or null if no alarms are configured */ public static function get_next_alarm($rec, $type = 'event') { if ( (empty($rec['valarms']) && empty($rec['alarms'])) || !empty($rec['cancelled']) || (!empty($rec['status']) && $rec['status'] == 'CANCELLED') ) { return null; } if ($type == 'task') { $timezone = self::get_instance()->timezone; if (!empty($rec['startdate'])) { $time = !empty($rec['starttime']) ? $rec['starttime'] : '12:00'; $rec['start'] = new DateTime($rec['startdate'] . ' ' . $time, $timezone); } if (!empty($rec['date'])) { $time = !empty($rec['time']) ? $rec['time'] : '12:00'; $rec[!empty($rec['start']) ? 'end' : 'start'] = new DateTime($rec['date'] . ' ' . $time, $timezone); } } if (empty($rec['end'])) { $rec['end'] = $rec['start']; } // support legacy format if (empty($rec['valarms'])) { [$trigger, $action] = explode(':', $rec['alarms'], 2); if ($alarm = self::parse_alarm_value($trigger)) { $rec['valarms'] = [['action' => $action, 'trigger' => $alarm[3] ?: $alarm[0]]]; } } - // alarm ID eq. record ID by default to keep backwards compatibility - $alarm_id = $rec['id'] ?? null; - $alarm_prop = null; - $expires = new DateTime('now - 12 hours'); - $notify_at = null; + $expires = new DateTime('now - 12 hours'); + $result = null; + $notify_at = null; // handle multiple alarms foreach ($rec['valarms'] as $alarm) { $notify_time = null; if ($alarm['trigger'] instanceof DateTimeInterface) { $notify_time = $alarm['trigger']; } elseif (is_string($alarm['trigger'])) { $refdate = !empty($alarm['related']) && $alarm['related'] == 'END' ? $rec['end'] : $rec['start']; // abort if no reference date is available to compute notification time if (!is_a($refdate, 'DateTime')) { continue; } // TODO: for all-day events, take start @ 00:00 as reference date ? try { $interval = new DateInterval(trim($alarm['trigger'], '+-')); $interval->invert = $alarm['trigger'][0] == '-' ? 1 : 0; $notify_time = clone $refdate; $notify_time->add($interval); } catch (Exception $e) { rcube::raise_error($e, true); continue; } } if ($notify_time && (!$notify_at || ($notify_time > $notify_at && $notify_time > $expires))) { - $notify_at = $notify_time; - $action = $alarm['action'] ?? null; - $alarm_prop = $alarm; + $alarm_id = substr(md5($rec['id'] ?? ($rec['uid'] ?? 'none')), 0, 16); + $notify_at = $notify_time; // generate a unique alarm ID if multiple alarms are set if (count($rec['valarms']) > 1) { - $rec_id = substr(md5($rec['id'] ?? 'none'), 0, 16); - $alarm_id = $rec_id . '-' . $notify_at->format('Ymd\THis'); + $alarm_id = $alarm_id . '-' . $notify_at->format('Ymd\THis'); } + + $result = [ + 'time' => $notify_at->format('U'), + 'action' => !empty($alarm['action']) ? strtoupper($alarm['action']) : 'DISPLAY', + 'id' => $alarm_id, + 'prop' => $alarm, + ]; } } - return !$notify_at ? null : [ - 'time' => $notify_at->format('U'), - 'action' => !empty($action) ? strtoupper($action) : 'DISPLAY', - 'id' => $alarm_id, - 'prop' => $alarm_prop, - ]; + return $result; } /** * Handler for keep-alive requests * This will check for pending notifications and pass them to the client */ public function refresh($attr) { // collect pending alarms from all providers (e.g. calendar, tasks) $plugin = $this->rc->plugins->exec_hook('pending_alarms', [ 'time' => time(), 'alarms' => [], ]); if (!$plugin['abort'] && !empty($plugin['alarms'])) { // make sure texts and env vars are available on client $this->add_texts('localization/', true); $this->rc->output->add_label('close'); $this->rc->output->set_env('snooze_select', $this->snooze_select()); $this->rc->output->command('plugin.display_alarms', $this->_alarms_output($plugin['alarms'])); } } /** * Handler for alarm dismiss/snooze requests */ public function alarms_action() { - // $action = rcube_utils::get_input_value('action', rcube_utils::INPUT_GPC); - $data = rcube_utils::get_input_value('data', rcube_utils::INPUT_POST, true); + $data = rcube_utils::get_input_value('data', rcube_utils::INPUT_POST, true); $data['ids'] = explode(',', $data['id']); + $data['success'] = false; + $plugin = $this->rc->plugins->exec_hook('dismiss_alarms', $data); if (!empty($plugin['success'])) { $this->rc->output->show_message('successfullysaved', 'confirmation'); } else { $this->rc->output->show_message('calendar.errorsaving', 'error'); } } /** * Generate reduced and streamlined output for pending alarms */ private function _alarms_output($alarms) { $out = []; foreach ($alarms as $alarm) { $out[] = [ 'id' => $alarm['id'], 'start' => !empty($alarm['start']) ? $this->adjust_timezone($alarm['start'])->format('c') : '', 'end' => !empty($alarm['end']) ? $this->adjust_timezone($alarm['end'])->format('c') : '', 'allDay' => !empty($alarm['allday']), 'action' => $alarm['action'], 'title' => $alarm['title'], - 'location' => $alarm['location'], - 'calendar' => $alarm['calendar'], + 'location' => $alarm['location'] ?? null, ]; } return $out; } /** * Render a dropdown menu to choose snooze time */ private function snooze_select($attrib = []) { $steps = [ 5 => 'repeatinmin', 10 => 'repeatinmin', 15 => 'repeatinmin', 20 => 'repeatinmin', 30 => 'repeatinmin', 60 => 'repeatinhr', 120 => 'repeatinhrs', 1440 => 'repeattomorrow', 10080 => 'repeatinweek', ]; $items = []; foreach ($steps as $n => $label) { $items[] = html::tag('li', null, html::a( ['href' => "#" . ($n * 60), 'class' => 'active'], $this->gettext(['name' => $label, 'vars' => ['min' => $n % 60, 'hrs' => intval($n / 60)]]) )); } return html::tag('ul', $attrib + ['class' => 'toolbarmenu menu'], implode("\n", $items), html::$common_attrib); } /********* Recurrence rules handling ********/ /** * Render localized text describing the recurrence rule of an event */ public function recurrence_text($rrule) { $limit = 10; $exdates = []; $format = $this->rc->config->get('calendar_date_format', $this->defaults['calendar_date_format']); $format = self::to_php_date_format($format); $format_fn = function ($dt) use ($format) { return rcmail::get_instance()->format_date($dt, $format); }; if (!empty($rrule['EXDATE']) && is_array($rrule['EXDATE'])) { $exdates = array_map($format_fn, $rrule['EXDATE']); } if (empty($rrule['FREQ']) && !empty($rrule['RDATE'])) { $rdates = array_map($format_fn, $rrule['RDATE']); $more = false; if (!empty($exdates)) { $rdates = array_diff($rdates, $exdates); } if (count($rdates) > $limit) { $rdates = array_slice($rdates, 0, $limit); $more = true; } return $this->gettext('ondate') . ' ' . implode(', ', $rdates) . ($more ? '...' : ''); } $output = sprintf('%s %d ', $this->gettext('every'), !empty($rrule['INTERVAL']) ? $rrule['INTERVAL'] : 1); switch ($rrule['FREQ']) { case 'DAILY': $output .= $this->gettext('days'); break; case 'WEEKLY': $output .= $this->gettext('weeks'); break; case 'MONTHLY': $output .= $this->gettext('months'); break; case 'YEARLY': $output .= $this->gettext('years'); break; } if (!empty($rrule['COUNT'])) { $until = $this->gettext(['name' => 'forntimes', 'vars' => ['nr' => $rrule['COUNT']]]); } elseif (!empty($rrule['UNTIL'])) { $until = $this->gettext('recurrencend') . ' ' . $this->rc->format_date($rrule['UNTIL'], $format); } else { $until = $this->gettext('forever'); } $output .= ', ' . $until; if (!empty($exdates)) { $more = false; if (count($exdates) > $limit) { $exdates = array_slice($exdates, 0, $limit); $more = true; } $output .= '; ' . $this->gettext('except') . ' ' . implode(', ', $exdates) . ($more ? '...' : ''); } return $output; } /** * Generate the form for recurrence settings */ public function recurrence_form($attrib = []) { $html = ''; switch ($attrib['part']) { // frequency selector case 'frequency': $select = new html_select(['name' => 'frequency', 'id' => 'edit-recurrence-frequency', 'class' => 'form-control']); $select->add($this->gettext('never'), ''); $select->add($this->gettext('daily'), 'DAILY'); $select->add($this->gettext('weekly'), 'WEEKLY'); $select->add($this->gettext('monthly'), 'MONTHLY'); $select->add($this->gettext('yearly'), 'YEARLY'); $select->add($this->gettext('rdate'), 'RDATE'); $html = html::label(['for' => 'edit-recurrence-frequency', 'class' => 'col-form-label col-sm-2'], $this->gettext('frequency')) . html::div('col-sm-10', $select->show('')); break; // daily recurrence case 'daily': $select = $this->interval_selector(['name' => 'interval', 'class' => 'edit-recurrence-interval form-control', 'id' => 'edit-recurrence-interval-daily']); $html = html::div($attrib, html::label(['for' => 'edit-recurrence-interval-daily', 'class' => 'col-form-label col-sm-2'], $this->gettext('every')) . html::div('col-sm-10 input-group', $select->show(1) . html::span('label-after input-group-append', html::span('input-group-text', $this->gettext('days'))))); break; // weekly recurrence form case 'weekly': $select = $this->interval_selector(['name' => 'interval', 'class' => 'edit-recurrence-interval form-control', 'id' => 'edit-recurrence-interval-weekly']); $html = html::div($attrib, html::label(['for' => 'edit-recurrence-interval-weekly', 'class' => 'col-form-label col-sm-2'], $this->gettext('every')) . html::div('col-sm-10 input-group', $select->show(1) . html::span('label-after input-group-append', html::span('input-group-text', $this->gettext('weeks'))))); // weekday selection $daymap = ['sun','mon','tue','wed','thu','fri','sat']; $checkbox = new html_checkbox(['name' => 'byday', 'class' => 'edit-recurrence-weekly-byday']); $first = $this->rc->config->get('calendar_first_day', 1); for ($weekdays = '', $j = $first; $j <= $first + 6; $j++) { $d = $j % 7; $weekdays .= html::label( ['class' => 'weekday'], $checkbox->show('', ['value' => strtoupper(substr($daymap[$d], 0, 2))]) . $this->gettext($daymap[$d]) ) . ' '; } $html .= html::div($attrib, html::label(['class' => 'col-form-label col-sm-2'], $this->gettext('bydays')) . html::div('col-sm-10 form-control-plaintext', $weekdays)); break; // monthly recurrence form case 'monthly': $select = $this->interval_selector(['name' => 'interval', 'class' => 'edit-recurrence-interval form-control', 'id' => 'edit-recurrence-interval-monthly']); $html = html::div($attrib, html::label(['for' => 'edit-recurrence-interval-monthly', 'class' => 'col-form-label col-sm-2'], $this->gettext('every')) . html::div('col-sm-10 input-group', $select->show(1) . html::span('label-after input-group-append', html::span('input-group-text', $this->gettext('months'))))); $checkbox = new html_checkbox(['name' => 'bymonthday', 'class' => 'edit-recurrence-monthly-bymonthday']); for ($monthdays = '', $d = 1; $d <= 31; $d++) { $monthdays .= html::label(['class' => 'monthday'], $checkbox->show('', ['value' => $d]) . $d); $monthdays .= $d % 7 ? ' ' : html::br(); } // rule selectors $radio = new html_radiobutton(['name' => 'repeatmode', 'class' => 'edit-recurrence-monthly-mode']); $table = new html_table(['cols' => 2, 'border' => 0, 'cellpadding' => 0, 'class' => 'formtable']); $table->add('label', html::label(null, $radio->show('BYMONTHDAY', ['value' => 'BYMONTHDAY']) . ' ' . $this->gettext('each'))); $table->add(null, $monthdays); $table->add('label', html::label(null, $radio->show('', ['value' => 'BYDAY']) . ' ' . $this->gettext('every'))); $table->add('recurrence-onevery', $this->rrule_selectors($attrib['part'])); $html .= html::div($attrib, html::label(['class' => 'col-form-label col-sm-2'], $this->gettext('bydays')) . html::div('col-sm-10 form-control-plaintext', $table->show())); break; // annually recurrence form case 'yearly': $select = $this->interval_selector(['name' => 'interval', 'class' => 'edit-recurrence-interval form-control', 'id' => 'edit-recurrence-interval-yearly']); $html = html::div($attrib, html::label(['for' => 'edit-recurrence-interval-yearly', 'class' => 'col-form-label col-sm-2'], $this->gettext('every')) . html::div('col-sm-10 input-group', $select->show(1) . html::span('label-after input-group-append', html::span('input-group-text', $this->gettext('years'))))); // month selector $monthmap = ['','jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec']; $checkbox = new html_checkbox(['name' => 'bymonth', 'class' => 'edit-recurrence-yearly-bymonth']); for ($months = '', $m = 1; $m <= 12; $m++) { $months .= html::label(['class' => 'month'], $checkbox->show(null, ['value' => $m]) . $this->gettext($monthmap[$m])); $months .= $m % 4 ? ' ' : html::br(); } $html .= html::div($attrib, html::label(['class' => 'col-form-label col-sm-2'], $this->gettext('bymonths')) . html::div( 'col-sm-10 form-control-plaintext', html::div(['id' => 'edit-recurrence-yearly-bymonthblock'], $months) . html::div('recurrence-onevery', $this->rrule_selectors($attrib['part'], '---')) )); break; // end of recurrence form case 'until': $radio = new html_radiobutton(['name' => 'repeat', 'class' => 'edit-recurrence-until']); $select = $this->interval_selector(['name' => 'times', 'id' => 'edit-recurrence-repeat-times', 'class' => 'form-control']); $input = new html_inputfield(['name' => 'untildate', 'id' => 'edit-recurrence-enddate', 'size' => '10', 'class' => 'form-control datepicker']); $html = html::div( 'line first', $radio->show('', ['value' => '', 'id' => 'edit-recurrence-repeat-forever']) . ' ' . html::label('edit-recurrence-repeat-forever', $this->gettext('forever')) ); $label = $this->gettext('ntimes'); if (strpos($label, '$') === 0) { $label = str_replace('$n', '', $label); $group = $select->show(1) . html::span('input-group-append', html::span('input-group-text', rcube::Q($label))); } else { $label = str_replace('$n', '', $label); $group = html::span('input-group-prepend', html::span('input-group-text', rcube::Q($label))) . $select->show(1); } $html .= html::div( 'line', $radio->show('', ['value' => 'count', 'id' => 'edit-recurrence-repeat-count']) . ' ' . html::label('edit-recurrence-repeat-count', $this->gettext('for')) . ' ' . html::span('input-group', $group) ); $html .= html::div( 'line', $radio->show('', ['value' => 'until', 'id' => 'edit-recurrence-repeat-until', 'aria-label' => $this->gettext('untilenddate')]) . ' ' . html::label('edit-recurrence-repeat-until', $this->gettext('untildate')) . ' ' . $input->show('', ['aria-label' => $this->gettext('untilenddate')]) ); $html = html::div($attrib, html::label(['class' => 'col-form-label col-sm-2'], ucfirst($this->gettext('recurrencend'))) . html::div('col-sm-10', $html)); break; case 'rdate': $ul = html::tag('ul', ['id' => 'edit-recurrence-rdates', 'class' => 'recurrence-rdates'], ''); $input = new html_inputfield(['name' => 'rdate', 'id' => 'edit-recurrence-rdate-input', 'size' => "10", 'class' => 'form-control datepicker']); $button = new html_inputfield(['type' => 'button', 'class' => 'button add', 'value' => $this->gettext('addrdate')]); $html = html::div($attrib, html::label(['class' => 'col-form-label col-sm-2', 'for' => 'edit-recurrence-rdate-input'], $this->gettext('bydates')) . html::div('col-sm-10', $ul . html::div('inputform', $input->show() . $button->show()))); break; } return $html; } /** * Input field for interval selection */ private function interval_selector($attrib) { $select = new html_select($attrib); $select->add(range(1, 30), range(1, 30)); return $select; } /** * Drop-down menus for recurrence rules like "each last sunday of" */ private function rrule_selectors($part, $noselect = null) { // rule selectors $select_prefix = new html_select(['name' => 'bydayprefix', 'id' => "edit-recurrence-$part-prefix", 'class' => 'form-control']); if ($noselect) { $select_prefix->add($noselect, ''); } $select_prefix->add( [ $this->gettext('first'), $this->gettext('second'), $this->gettext('third'), $this->gettext('fourth'), $this->gettext('last'), ], [1, 2, 3, 4, -1] ); $select_wday = new html_select(['name' => 'byday', 'id' => "edit-recurrence-$part-byday", 'class' => 'form-control']); if ($noselect) { $select_wday->add($noselect, ''); } $daymap = ['sunday','monday','tuesday','wednesday','thursday','friday','saturday']; $first = $this->rc->config->get('calendar_first_day', 1); for ($j = $first; $j <= $first + 6; $j++) { $d = $j % 7; $select_wday->add($this->gettext($daymap[$d]), strtoupper(substr($daymap[$d], 0, 2))); } return $select_prefix->show() . ' ' . $select_wday->show(); } /** * Convert the recurrence settings to be processed on the client */ public function to_client_recurrence($recurrence, $allday = false) { if (!empty($recurrence['UNTIL'])) { $recurrence['UNTIL'] = $this->adjust_timezone($recurrence['UNTIL'], $allday)->format('c'); } // format RDATE values if (!empty($recurrence['RDATE'])) { $libcal = $this; $recurrence['RDATE'] = array_map(function ($rdate) use ($libcal) { return $libcal->adjust_timezone($rdate, true)->format('c'); }, (array) $recurrence['RDATE']); } unset($recurrence['EXCEPTIONS']); return $recurrence; } /** * Process the alarms values submitted by the client */ public function from_client_recurrence($recurrence, $start = null) { if (is_array($recurrence) && !empty($recurrence['UNTIL'])) { $recurrence['UNTIL'] = new DateTime($recurrence['UNTIL'], $this->timezone); } if (is_array($recurrence) && !empty($recurrence['RDATE'])) { $tz = $this->timezone; $recurrence['RDATE'] = array_map(function ($rdate) use ($tz, $start) { try { $dt = new DateTime($rdate, $tz); if (is_a($start, 'DateTime')) { $dt->setTime($start->format('G'), $start->format('i')); } return $dt; } catch (Exception $e) { return null; } }, $recurrence['RDATE']); } return $recurrence; } /********* iTip message detection *********/ /** * Check mail message structure of there are .ics files attached */ public function mail_message_load($p) { $this->ical_parts = []; $this->mail_ical_parser = null; $this->ical_message = $p['object']; $itip_part = null; // check all message parts for .ics files foreach ((array)$this->ical_message->mime_parts as $part) { /** @var rcube_message_part $part */ if (self::part_is_vcalendar($part, $this->ical_message)) { if (!empty($part->ctype_parameters['method'])) { $itip_part = $part->mime_id; } else { $this->ical_parts[] = $part->mime_id; } } } // priorize part with method parameter if ($itip_part) { $this->ical_parts = [$itip_part]; } } /** * Getter for the parsed iCal objects attached to the current email message * * @return libcalendaring_vcalendar Parser instance with the parsed objects */ public function get_mail_ical_objects() { // create parser and load ical objects if (!$this->mail_ical_parser) { $this->mail_ical_parser = $this->get_ical(); foreach ($this->ical_parts as $mime_id) { $part = $this->ical_message->mime_parts[$mime_id]; $charset = ($part->ctype_parameters['charset'] ?? '') ?: RCUBE_CHARSET; $body = $this->ical_message->get_part_body($mime_id, true); if ($body === null || $body === false) { rcube::raise_error("Failed to get (iTip) body for message part: {$this->ical_message->uid}/{$mime_id}", true); continue; } $this->mail_ical_parser->import($body, $charset); // check if the parsed object is an instance of a recurring event/task array_walk($this->mail_ical_parser->objects, 'libcalendaring::identify_recurrence_instance'); // stop on the part that has an iTip method specified if (count($this->mail_ical_parser->objects) && $this->mail_ical_parser->method) { $this->mail_ical_parser->message_date = $this->ical_message->headers->date; $this->mail_ical_parser->mime_id = $mime_id; // store the message's sender address for comparisons $from = rcube_mime::decode_address_list($this->ical_message->headers->from, 1, true, null, true); $this->mail_ical_parser->sender = !empty($from) ? $from[1] : ''; if (!empty($this->mail_ical_parser->sender)) { foreach ($this->mail_ical_parser->objects as $i => $object) { $this->mail_ical_parser->objects[$i]['_sender'] = $this->mail_ical_parser->sender; $this->mail_ical_parser->objects[$i]['_sender_utf'] = rcube_utils::idn_to_utf8($this->mail_ical_parser->sender); } } break; } } } return $this->mail_ical_parser; } /** * Read the given mime message from IMAP and parse ical data * * @param string $mbox Mailbox name * @param string $uid Message UID * @param string $mime_id Message part ID and object index (e.g. '1.2:0') * @param string $type Object type filter (optional) * * @return ?array Hash array with the parsed iCal */ public function mail_get_itip_object($mbox, $uid, $mime_id, $type = null) { if (empty($uid) || empty($mime_id)) { return null; } $charset = RCUBE_CHARSET; // establish imap connection $imap = $this->rc->get_storage(); $imap->set_folder($mbox); [$mime_id, $index] = explode(':', $mime_id); $part = $imap->get_message_part($uid, $mime_id); $headers = $imap->get_message_headers($uid); $parser = $this->get_ical(); if ($part) { if (!empty($part->ctype_parameters['charset'])) { $charset = $part->ctype_parameters['charset']; } $objects = $parser->import($part, $charset); } // successfully parsed events/tasks? if (!empty($objects) && ($object = $objects[$index]) && (!$type || $object['_type'] == $type)) { if ($parser->method) { $object['_method'] = $parser->method; } // store the message's sender address for comparisons $from = rcube_mime::decode_address_list($headers->from, 1, true, null, true); $object['_sender'] = !empty($from) ? $from[1] : ''; $object['_sender_utf'] = rcube_utils::idn_to_utf8($object['_sender']); // check if this is an instance of a recurring event/task self::identify_recurrence_instance($object); return $object; } return null; } /** * Checks if specified message part is a vcalendar data * * @param rcube_message_part $part Part object * @param rcube_message $message Message object * * @return bool True if part is of type vcard */ public static function part_is_vcalendar($part, $message = null) { // First check if the message is "valid" (i.e. not multipart/report) if ($message) { $level = explode('.', $part->mime_id); while (array_pop($level) !== null) { $id = implode('.', $level) ?: 0; $parent = !empty($message->mime_parts[$id]) ? $message->mime_parts[$id] : null; if ($parent && $parent->mimetype == 'multipart/report') { return false; } } } return ( in_array($part->mimetype, ['text/calendar', 'text/x-vcalendar', 'application/ics']) || // Apple sends files as application/x-any (!?) ($part->mimetype == 'application/x-any' && !empty($part->filename) && preg_match('/\.ics$/i', $part->filename)) ); } /** * Single occourrences of recurring events are identified by their RECURRENCE-ID property * in iCal which is represented as 'recurrence_date' in our internal data structure. * * Check if such a property exists and derive the '_instance' identifier and '_savemode' * attributes which are used in the storage backend to identify the nested exception item. */ public static function identify_recurrence_instance(&$object) { // for savemode=all, remove recurrence instance identifiers if (!empty($object['_savemode']) && $object['_savemode'] == 'all' && !empty($object['recurrence'])) { unset($object['_instance'], $object['recurrence_date']); } // set instance and 'savemode' according to recurrence-id elseif (!empty($object['recurrence_date']) && $object['recurrence_date'] instanceof DateTimeInterface) { $object['_instance'] = self::recurrence_instance_identifier($object); $object['_savemode'] = !empty($object['thisandfuture']) ? 'future' : 'current'; } elseif (!empty($object['recurrence_id']) && !empty($object['_instance'])) { if (strlen($object['_instance']) > 4) { $object['recurrence_date'] = rcube_utils::anytodatetime($object['_instance'], $object['start']->getTimezone()); } else { $object['recurrence_date'] = clone $object['start']; } } } /** * Return a date() format string to render identifiers for recurrence instances * * @param array $event Hash array with event properties * * @return string Format string */ public static function recurrence_id_format($event) { return !empty($event['allday']) ? 'Ymd' : 'Ymd\THis'; } /** * Return the identifer for the given instance of a recurring event * * @param array $event Hash array with event properties * @param ?bool $allday All-day flag from the main event * * @return mixed Format string or null if identifier cannot be generated */ public static function recurrence_instance_identifier($event, $allday = null) { $instance_date = !empty($event['recurrence_date']) ? $event['recurrence_date'] : $event['start']; if ($instance_date instanceof DateTimeInterface) { // According to RFC5545 (3.8.4.4) RECURRENCE-ID format should // be date/date-time depending on the main event type, not the exception if ($allday === null) { $allday = !empty($event['allday']); } return $instance_date->format($allday ? 'Ymd' : 'Ymd\THis'); } } /** * Check if a specified event is "identical" to the specified recurrence exception * * @param array $event Hash array with occurrence properties * @param array $exception Hash array with exception properties * * @return bool */ public static function is_recurrence_exception($event, $exception) { $instance_date = !empty($event['recurrence_date']) ? $event['recurrence_date'] : $event['start']; $exception_date = !empty($exception['recurrence_date']) ? $exception['recurrence_date'] : $exception['start']; if ($instance_date instanceof DateTimeInterface && $exception_date instanceof DateTimeInterface) { // Timezone??? return $instance_date->format('Ymd') === $exception_date->format('Ymd'); } return false; } /********* Attendee handling functions *********/ /** * Handler for attendee group expansion requests */ public function expand_attendee_group() { $id = rcube_utils::get_input_value('id', rcube_utils::INPUT_POST); $data = rcube_utils::get_input_value('data', rcube_utils::INPUT_POST, true); $result = ['id' => $id, 'members' => []]; $maxnum = 500; // iterate over all autocomplete address books (we don't know the source of the group) foreach ((array)$this->rc->config->get('autocomplete_addressbooks', 'sql') as $abook_id) { if (($abook = $this->rc->get_address_book($abook_id)) && $abook->groups) { foreach ($abook->list_groups($data['name'], 1) as $group) { // this is the matching group to expand if (in_array($data['email'], (array)$group['email'])) { $abook->set_pagesize($maxnum); $abook->set_group($group['ID']); // get all members $res = $abook->list_records($this->rc->config->get('contactlist_fields')); // handle errors (e.g. sizelimit, timelimit) if ($abook->get_error()) { $result['error'] = $this->rc->gettext('expandattendeegrouperror', 'libcalendaring'); $res = false; } // check for maximum number of members (we don't wanna bloat the UI too much) elseif ($res->count > $maxnum) { $result['error'] = $this->rc->gettext('expandattendeegroupsizelimit', 'libcalendaring'); $res = false; } while ($res && ($member = $res->iterate())) { $emails = (array)$abook->get_col_values('email', $member, true); if (!empty($emails) && ($email = array_shift($emails))) { $result['members'][] = [ 'email' => $email, 'name' => rcube_addressbook::compose_list_name($member), ]; } } break 2; } } } } $this->rc->output->command('plugin.expand_attendee_callback', $result); } /** * Merge attendees of the old and new event version * with keeping current user and his delegatees status * * @param array &$new New object data * @param array $old Old object data * @param bool $status New status of the current user */ public function merge_attendees(&$new, $old, $status = null) { if (empty($status)) { $emails = $this->get_user_emails(); $delegates = []; $attendees = []; // keep attendee status of the current user foreach ((array) $new['attendees'] as $i => $attendee) { if (empty($attendee['email'])) { continue; } $attendees[] = $email = strtolower($attendee['email']); if (in_array($email, $emails)) { foreach ($old['attendees'] as $_attendee) { if ($attendee['email'] == $_attendee['email']) { $new['attendees'][$i] = $_attendee; if ($_attendee['status'] == 'DELEGATED' && ($email = $_attendee['delegated-to'])) { $delegates[] = strtolower($email); } break; } } } } // make sure delegated attendee is not lost foreach ($delegates as $delegatee) { if (!in_array($delegatee, $attendees)) { foreach ((array) $old['attendees'] as $attendee) { if ($attendee['email'] && ($email = strtolower($attendee['email'])) && $email == $delegatee) { $new['attendees'][] = $attendee; break; } } } } } // We also make sure that status of any attendee // is not overriden by NEEDS-ACTION if it was already set // which could happen if you work with shared events foreach ((array) $new['attendees'] as $i => $attendee) { if ($attendee['email'] && ($attendee['status'] ?? '') == 'NEEDS-ACTION') { foreach ($old['attendees'] as $_attendee) { if ($attendee['email'] == $_attendee['email']) { $new['attendees'][$i]['status'] = $_attendee['status']; unset($new['attendees'][$i]['rsvp']); break; } } } } } /********* Static utility functions *********/ /** * Convert the internal structured data into a vcalendar rrule 2.0 string */ public static function to_rrule($recurrence, $allday = false) { if (is_string($recurrence)) { return $recurrence; } $rrule = ''; foreach ((array)$recurrence as $k => $val) { $k = strtoupper($k); switch ($k) { case 'UNTIL': // convert to UTC according to RFC 5545 if (is_a($val, 'DateTime')) { if (!$allday && empty($val->_dateonly)) { $until = clone $val; $until->setTimezone(new DateTimeZone('UTC')); $val = $until->format('Ymd\THis\Z'); } else { $val = $val->format('Ymd'); } } break; case 'RDATE': case 'EXDATE': foreach ((array)$val as $i => $ex) { if (is_a($ex, 'DateTime')) { $val[$i] = $ex->format('Ymd\THis'); } } $val = implode(',', (array)$val); break; case 'EXCEPTIONS': continue 2; } if (strlen($val)) { $rrule .= $k . '=' . $val . ';'; } } return rtrim($rrule, ';'); } /** * Convert from fullcalendar date format to PHP date() format string */ public static function to_php_date_format($from) { if (!is_string($from)) { return ''; } // "dd.MM.yyyy HH:mm:ss" => "d.m.Y H:i:s" return strtr(strtr($from, [ 'YYYY' => 'Y', 'YY' => 'y', 'yyyy' => 'Y', 'yy' => 'y', 'MMMM' => 'F', 'MMM' => 'M', 'MM' => 'm', 'M' => 'n', 'dddd' => 'l', 'ddd' => 'D', 'DD' => 'd', 'D' => 'j', 'HH' => '**', 'hh' => '%%', 'H' => 'G', 'h' => 'g', 'mm' => 'i', 'ss' => 's', 'TT' => 'A', 'tt' => 'a', 'T' => 'A', 't' => 'a', 'u' => 'c', ]), [ '**' => 'H', '%%' => 'h', ]); } /** * Convert from PHP date() format to fullcalendar (MomentJS) format string */ public static function from_php_date_format($from) { if (!is_string($from)) { return ''; } // "d.m.Y H:i:s" => "dd.MM.yyyy HH:mm:ss" return strtr($from, [ 'y' => 'YY', 'Y' => 'YYYY', 'M' => 'MMM', 'F' => 'MMMM', 'm' => 'MM', 'n' => 'M', 'j' => 'D', 'd' => 'DD', 'D' => 'ddd', 'l' => 'dddd', 'H' => 'HH', 'h' => 'hh', 'G' => 'H', 'g' => 'h', 'i' => 'mm', 's' => 'ss', 'c' => '', ]); } } diff --git a/plugins/tasklist/tasklist.php b/plugins/tasklist/tasklist.php index 8b930240..675a0485 100644 --- a/plugins/tasklist/tasklist.php +++ b/plugins/tasklist/tasklist.php @@ -1,2545 +1,2547 @@ * * Copyright (C) 2012, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /** * Tasklist plugin * * @property tasklist_driver $driver * @property libcalendaring_vcalendar $ical * @property libcalendaring_itip $itip */ #[AllowDynamicProperties] class tasklist extends rcube_plugin { public const FILTER_MASK_TODAY = 1; public const FILTER_MASK_TOMORROW = 2; public const FILTER_MASK_WEEK = 4; public const FILTER_MASK_LATER = 8; public const FILTER_MASK_NODATE = 16; public const FILTER_MASK_OVERDUE = 32; public const FILTER_MASK_FLAGGED = 64; public const FILTER_MASK_COMPLETE = 128; public const FILTER_MASK_ASSIGNED = 256; public const FILTER_MASK_MYTASKS = 512; public const SESSION_KEY = 'tasklist_temp'; public static $filter_masks = [ 'today' => self::FILTER_MASK_TODAY, 'tomorrow' => self::FILTER_MASK_TOMORROW, 'week' => self::FILTER_MASK_WEEK, 'later' => self::FILTER_MASK_LATER, 'nodate' => self::FILTER_MASK_NODATE, 'overdue' => self::FILTER_MASK_OVERDUE, 'flagged' => self::FILTER_MASK_FLAGGED, 'complete' => self::FILTER_MASK_COMPLETE, 'assigned' => self::FILTER_MASK_ASSIGNED, 'mytasks' => self::FILTER_MASK_MYTASKS, ]; public $task = '?(?!login|logout).*'; public $allowed_prefs = ['tasklist_sort_col','tasklist_sort_order']; public $rc; public $lib; public $timezone; public $ui; public $home; // declare public to be used in other classes // These are handled by __get() // public $driver; // public $itip; // public $ical; private $collapsed_tasks = []; private $message_tasks = []; private $task_titles = []; private $task_tree = []; /** * Plugin initialization. */ public function init() { $this->require_plugin('libcalendaring'); $this->require_plugin('libkolab'); // load plugin configuration $this->load_config(); $this->rc = rcube::get_instance(); $this->lib = libcalendaring::get_instance(); $this->timezone = $this->lib->timezone; $this->register_task('tasks'); $this->add_hook('startup', [$this, 'startup']); $this->add_hook('user_delete', [$this, 'user_delete']); } /** * Startup hook */ public function startup($args) { // the tasks module can be enabled/disabled by the kolab_auth plugin if ($this->rc->config->get('tasklist_disabled', false) || !$this->rc->config->get('tasklist_enabled', true)) { return; } // load localizations $this->add_texts('localization/', $args['task'] == 'tasks' && (!$args['action'] || $args['action'] == 'print')); $this->rc->load_language($_SESSION['language'], ['tasks.tasks' => $this->gettext('navtitle')]); // add label for task title if ($args['task'] == 'tasks' && $args['action'] != 'save-pref') { $this->load_driver(); // register calendar actions $this->register_action('index', [$this, 'tasklist_view']); $this->register_action('task', [$this, 'task_action']); $this->register_action('tasklist', [$this, 'tasklist_action']); $this->register_action('counts', [$this, 'fetch_counts']); $this->register_action('fetch', [$this, 'fetch_tasks']); $this->register_action('print', [$this, 'print_tasks']); $this->register_action('dialog-ui', [$this, 'mail_message2task']); $this->register_action('get-attachment', [$this, 'attachment_get']); $this->register_action('upload', [$this, 'attachment_upload']); $this->register_action('import', [$this, 'import_tasks']); $this->register_action('export', [$this, 'export_tasks']); $this->register_action('mailimportitip', [$this, 'mail_import_itip']); $this->register_action('mailimportattach', [$this, 'mail_import_attachment']); $this->register_action('itip-status', [$this, 'task_itip_status']); $this->register_action('itip-remove', [$this, 'task_itip_remove']); $this->register_action('itip-decline-reply', [$this, 'mail_itip_decline_reply']); $this->register_action('itip-delegate', [$this, 'mail_itip_delegate']); $this->add_hook('refresh', [$this, 'refresh']); $this->rc->plugins->register_action('plugin.share-invitation', $this->ID, [$this, 'share_invitation']); $this->collapsed_tasks = array_filter(explode(',', $this->rc->config->get('tasklist_collapsed_tasks', ''))); } elseif ($args['task'] == 'mail') { if ($args['action'] == 'show' || $args['action'] == 'preview') { if ($this->rc->config->get('tasklist_mail_embed', true)) { $this->add_hook('message_load', [$this, 'mail_message_load']); } $this->add_hook('template_object_messagebody', [$this, 'mail_messagebody_html']); } // add 'Create event' item to message menu if ($this->api->output->type == 'html' && (empty($_GET['_rel']) || $_GET['_rel'] != 'task')) { $this->api->add_content( html::tag( 'li', ['role' => 'menuitem'], $this->api->output->button([ 'command' => 'tasklist-create-from-mail', 'label' => 'tasklist.createfrommail', 'type' => 'link', 'classact' => 'icon taskaddlink active', 'class' => 'icon taskaddlink disabled', 'innerclass' => 'icon taskadd', ]) ), 'messagemenu' ); $this->api->output->add_label('tasklist.createfrommail'); } } if (!$this->rc->output->ajax_call && empty($this->rc->output->env['framed'])) { // A hack to replace "Edit/Share List" label with "Edit list", for non-Kolab drivers if ($args['task'] == 'tasks' && $this->rc->config->get('tasklist_driver', 'database') !== 'kolab') { $merge = ['tasklist.editlist' => $this->gettext('edlist')]; $this->rc->load_language(null, [], $merge); $this->rc->output->command('add_label', $merge); } $this->load_ui(); $this->ui->init(); } // add hooks for alarms handling $this->add_hook('pending_alarms', [$this, 'pending_alarms']); $this->add_hook('dismiss_alarms', [$this, 'dismiss_alarms']); } /** * */ private function load_ui() { if (!$this->ui) { require_once($this->home . '/tasklist_ui.php'); $this->ui = new tasklist_ui($this); } } /** * Helper method to load the backend driver according to local config */ private function load_driver() { if (!empty($this->driver)) { return; } $driver_name = $this->rc->config->get('tasklist_driver', 'database'); $driver_class = 'tasklist_' . $driver_name . '_driver'; require_once $this->home . '/drivers/tasklist_driver.php'; require_once $this->home . '/drivers/' . $driver_name . '/' . $driver_class . '.php'; $this->driver = new $driver_class($this); $this->rc->output->set_env('tasklist_driver', $driver_name); } /** * Dispatcher for task-related actions initiated by the client */ public function task_action() { $filter = intval(rcube_utils::get_input_value('filter', rcube_utils::INPUT_GPC)); $action = rcube_utils::get_input_value('action', rcube_utils::INPUT_GPC); $rec = rcube_utils::get_input_value('t', rcube_utils::INPUT_POST, true); $oldrec = $rec; $success = $got_msg = false; $refresh = []; // force notify if hidden + active $itip_send_option = (int)$this->rc->config->get('calendar_itip_send_option', 3); if ($itip_send_option === 1 && empty($rec['_reportpartstat'])) { $rec['_notify'] = 1; } switch ($action) { case 'new': $oldrec = null; $rec = $this->prepare_task($rec); $rec['uid'] = $this->generate_uid(); $temp_id = !empty($rec['tempid']) ? $rec['tempid'] : null; if ($success = $this->driver->create_task($rec)) { $refresh = $this->driver->get_task($rec); if ($temp_id) { $refresh['tempid'] = $temp_id; } $this->cleanup_task($rec); } break; case 'complete': $complete = intval(rcube_utils::get_input_value('complete', rcube_utils::INPUT_POST)); if (!($rec = $this->driver->get_task($rec))) { break; } $oldrec = $rec; $rec['status'] = $complete ? 'COMPLETED' : ($rec['complete'] > 0 ? 'IN-PROCESS' : 'NEEDS-ACTION'); // sent itip notifications if enabled (no user interaction here) if (($itip_send_option & 1)) { if ($this->is_attendee($rec)) { $rec['_reportpartstat'] = $rec['status']; } elseif ($this->is_organizer($rec)) { $rec['_notify'] = 1; } } // no break case 'edit': $oldrec = $this->driver->get_task($rec); $rec = $this->prepare_task($rec); $clone = $this->handle_recurrence($rec, $this->driver->get_task($rec)); if ($success = $this->driver->edit_task($rec)) { $new_task = $this->driver->get_task($rec); $new_task['tempid'] = $rec['id']; $refresh[] = $new_task; $this->cleanup_task($rec); // add clone from recurring task if ($clone && $this->driver->create_task($clone)) { $new_clone = $this->driver->get_task($clone); $new_clone['tempid'] = $clone['id']; $refresh[] = $new_clone; $this->driver->clear_alarms($rec['id']); } // move all childs if list assignment was changed if (!empty($rec['_fromlist']) && !empty($rec['list']) && $rec['_fromlist'] != $rec['list']) { foreach ($this->driver->get_childs(['id' => $rec['id'], 'list' => $rec['_fromlist']], true) as $cid) { $child = ['id' => $cid, 'list' => $rec['list'], '_fromlist' => $rec['_fromlist']]; if ($this->driver->move_task($child)) { $r = $this->driver->get_task($child); if ((bool)($filter & self::FILTER_MASK_COMPLETE) == $this->driver->is_complete($r)) { $r['tempid'] = $cid; $refresh[] = $r; } } } } } break; case 'move': foreach ((array)$rec['id'] as $id) { $r = $rec; $r['id'] = $id; if ($this->driver->move_task($r) && ($new_task = $this->driver->get_task($r))) { $new_task['tempid'] = $id; $refresh[] = $new_task; $success = true; // move all childs, too foreach ($this->driver->get_childs(['id' => $id, 'list' => $rec['_fromlist']], true) as $cid) { $child = $rec; $child['id'] = $cid; if ($this->driver->move_task($child)) { $r = $this->driver->get_task($child); if ((bool)($filter & self::FILTER_MASK_COMPLETE) == $this->driver->is_complete($r)) { $r['tempid'] = $cid; $refresh[] = $r; } } } } } break; case 'delete': $mode = intval(rcube_utils::get_input_value('mode', rcube_utils::INPUT_POST)); $oldrec = $this->driver->get_task($rec); if ($success = $this->driver->delete_task($rec, false)) { // delete/modify all childs foreach ($this->driver->get_childs($rec, $mode) as $cid) { $child = ['id' => $cid, 'list' => $rec['list']]; if ($mode == 1) { // delete all childs if ($this->driver->delete_task($child, false)) { if ($this->driver->undelete) { $_SESSION['tasklist_undelete'][$rec['id']][] = $cid; } } else { $success = false; } } else { $child['parent_id'] = strval($oldrec['parent_id']); $this->driver->edit_task($child); } } // update parent task to adjust list of children if (!empty($oldrec['parent_id'])) { $parent = ['id' => $oldrec['parent_id'], 'list' => $rec['list']]; if ($parent = $this->driver->get_task($parent)) { $refresh[] = $parent; } } } if (!$success) { $this->rc->output->command('plugin.reload_data'); } break; case 'undelete': if ($success = $this->driver->undelete_task($rec)) { $refresh[] = $this->driver->get_task($rec); foreach ((array)$_SESSION['tasklist_undelete'][$rec['id']] as $cid) { if ($this->driver->undelete_task($rec)) { $refresh[] = $this->driver->get_task($rec); } } } break; case 'collapse': foreach (explode(',', $rec['id']) as $rec_id) { if (intval(rcube_utils::get_input_value('collapsed', rcube_utils::INPUT_GPC))) { $this->collapsed_tasks[] = $rec_id; } else { $i = array_search($rec_id, $this->collapsed_tasks); if ($i !== false) { unset($this->collapsed_tasks[$i]); } } } $this->rc->user->save_prefs(['tasklist_collapsed_tasks' => implode(',', array_unique($this->collapsed_tasks))]); return; // avoid further actions case 'rsvp': $status = rcube_utils::get_input_value('status', rcube_utils::INPUT_GPC); $noreply = intval(rcube_utils::get_input_value('noreply', rcube_utils::INPUT_GPC)) || $status == 'needs-action'; $task = $this->driver->get_task($rec); $task['attendees'] = $rec['attendees']; $task['_type'] = 'task'; // send invitation to delegatee + add it as attendee if ($status == 'delegated' && $rec['to']) { $itip = $this->load_itip(); if ($itip->delegate_to($task, $rec['to'], (bool)$rec['rsvp'])) { $this->rc->output->show_message('tasklist.itipsendsuccess', 'confirmation'); $refresh[] = $task; $noreply = false; } } $rec = $task; if ($success = $this->driver->edit_task($rec)) { if (!$noreply) { // let the reply clause further down send the iTip message $rec['_reportpartstat'] = $status; } } break; case 'changelog': $data = $this->driver->get_task_changelog($rec); if (is_array($data) && !empty($data)) { $lib = $this->lib; $dtformat = $this->rc->config->get('date_format') . ' ' . $this->rc->config->get('time_format'); array_walk($data, function (&$change) use ($lib, $dtformat) { if ($change['date']) { $dt = $lib->adjust_timezone($change['date']); if ($dt instanceof DateTime) { $change['date'] = $this->rc->format_date($dt, $dtformat, false); } } }); $this->rc->output->command('plugin.task_render_changelog', $data); } else { $this->rc->output->command('plugin.task_render_changelog', false); } $got_msg = true; break; case 'diff': $data = $this->driver->get_task_diff($rec, $rec['rev1'], $rec['rev2']); if (is_array($data)) { // convert some properties, similar to self::_client_event() $lib = $this->lib; $date_format = $this->rc->config->get('date_format', 'Y-m-d'); $time_format = $this->rc->config->get('time_format', 'H:i'); array_walk($data['changes'], function (&$change, $i) use ($lib, $date_format, $time_format, $rec) { // convert date cols if (in_array($change['property'], ['date','start','created','changed'])) { if (!empty($change['old'])) { $dtformat = strlen($change['old']) == 10 ? $date_format : $date_format . ' ' . $time_format; $change['old_'] = $lib->adjust_timezone($change['old'], strlen($change['old']) == 10)->format($dtformat); } if (!empty($change['new'])) { $dtformat = strlen($change['new']) == 10 ? $date_format : $date_format . ' ' . $time_format; $change['new_'] = $lib->adjust_timezone($change['new'], strlen($change['new']) == 10)->format($dtformat); } } // create textual representation for alarms and recurrence if ($change['property'] == 'alarms') { if (is_array($change['old'])) { $change['old_'] = libcalendaring::alarm_text($change['old']); } if (is_array($change['new'])) { $change['new_'] = libcalendaring::alarm_text(array_merge((array)$change['old'], $change['new'])); } } if ($change['property'] == 'recurrence') { if (is_array($change['old'])) { $change['old_'] = $lib->recurrence_text($change['old']); } if (is_array($change['new'])) { $change['new_'] = $lib->recurrence_text(array_merge((array)$change['old'], $change['new'])); } } if ($change['property'] == 'complete') { $change['old_'] = intval($change['old']) . '%'; $change['new_'] = intval($change['new']) . '%'; } if ($change['property'] == 'attachments') { if (is_array($change['old'])) { $change['old']['classname'] = rcube_utils::file2class($change['old']['mimetype'], $change['old']['name']); } if (is_array($change['new'])) { $change['new'] = array_merge((array)$change['old'], $change['new']); $change['new']['classname'] = rcube_utils::file2class($change['new']['mimetype'], $change['new']['name']); } } // resolve parent_id to the refered task title for display if ($change['property'] == 'parent_id') { $change['property'] = 'parent-title'; if (!empty($change['old']) && ($old_parent = $this->driver->get_task(['id' => $change['old'], 'list' => $rec['list']]))) { $change['old_'] = $old_parent['title']; } if (!empty($change['new']) && ($new_parent = $this->driver->get_task(['id' => $change['new'], 'list' => $rec['list']]))) { $change['new_'] = $new_parent['title']; } } // compute a nice diff of description texts if ($change['property'] == 'description') { $change['diff_'] = libkolab::html_diff($change['old'], $change['new']); } }); $this->rc->output->command('plugin.task_show_diff', $data); } else { $this->rc->output->command('display_message', $this->gettext('objectdiffnotavailable'), 'error'); } $got_msg = true; break; case 'show': if ($rec = $this->driver->get_task_revison($rec, $rec['rev'])) { $this->encode_task($rec); $rec['readonly'] = 1; $this->rc->output->command('plugin.task_show_revision', $rec); } else { $this->rc->output->command('display_message', $this->gettext('objectnotfound'), 'error'); } $got_msg = true; break; case 'restore': if ($success = $this->driver->restore_task_revision($rec, $rec['rev'])) { $refresh = $this->driver->get_task($rec); $this->rc->output->command('display_message', $this->gettext(['name' => 'objectrestoresuccess', 'vars' => ['rev' => $rec['rev']]]), 'confirmation'); $this->rc->output->command('plugin.close_history_dialog'); } else { $this->rc->output->command('display_message', $this->gettext('objectrestoreerror'), 'error'); } $got_msg = true; break; } if ($success) { $this->rc->output->show_message('successfullysaved', 'confirmation'); $this->update_counts($oldrec, $refresh); } elseif (!$got_msg) { $this->rc->output->show_message('tasklist.errorsaving', 'error'); } // send out notifications if ($success && !empty($rec['_notify']) && ($rec['attendees'] || $oldrec['attendees'])) { // make sure we have the complete record $task = $action == 'delete' ? $oldrec : $this->driver->get_task($rec); // only notify if data really changed (TODO: do diff check on client already) if (!$oldrec || $action == 'delete' || self::task_diff($task, $oldrec)) { $sent = $this->notify_attendees($task, $oldrec, $action, $rec['_comment']); if ($sent > 0) { $this->rc->output->show_message('tasklist.itipsendsuccess', 'confirmation'); } elseif ($sent < 0) { $this->rc->output->show_message('tasklist.errornotifying', 'error'); } } } if ($success && !empty($rec['_reportpartstat']) && $rec['_reportpartstat'] != 'NEEDS-ACTION') { // get the full record after update if (empty($task)) { $task = $this->driver->get_task($rec); } // send iTip REPLY with the updated partstat if ($task['organizer'] && ($idx = $this->is_attendee($task)) !== false) { $sender = $task['attendees'][$idx]; $status = strtolower($sender['status']); if (!empty($_POST['comment'])) { $task['comment'] = rcube_utils::get_input_value('comment', rcube_utils::INPUT_POST); } $itip = $this->load_itip(); $itip->set_sender_email($sender['email']); if ($itip->send_itip_message($this->to_libcal($task), 'REPLY', $task['organizer'], 'itipsubject' . $status, 'itipmailbody' . $status)) { $this->rc->output->command('display_message', $this->gettext(['name' => 'sentresponseto', 'vars' => ['mailto' => $task['organizer']['name'] ?: $task['organizer']['email']]]), 'confirmation'); } else { $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error'); } } } // unlock client $this->rc->output->command('plugin.unlock_saving', $success); if (!empty($refresh)) { if (!empty($refresh['id'])) { $this->encode_task($refresh); } elseif (is_array($refresh)) { foreach ($refresh as $i => $r) { $this->encode_task($refresh[$i]); } } $this->rc->output->command('plugin.update_task', $refresh); } elseif ($success && ($action == 'delete' || $action == 'undelete')) { $this->rc->output->command('plugin.refresh_tagcloud'); } } /** * Load iTIP functions */ private function load_itip() { if (empty($this->itip)) { require_once __DIR__ . '/../libcalendaring/lib/libcalendaring_itip.php'; $this->itip = new libcalendaring_itip($this, 'tasklist'); $this->itip->set_rsvp_actions(['accepted','declined','delegated']); $this->itip->set_rsvp_status(['accepted','tentative','declined','delegated','in-process','completed']); } return $this->itip; } /** * repares new/edited task properties before save */ private function prepare_task($rec) { // try to be smart and extract date from raw input if (!empty($rec['raw'])) { foreach (['today','tomorrow','sunday','monday','tuesday','wednesday','thursday','friday','saturday','sun','mon','tue','wed','thu','fri','sat'] as $word) { $locwords[] = '/^' . preg_quote(mb_strtolower($this->gettext($word))) . '\b/i'; $normwords[] = $word; $datewords[] = $word; } foreach (['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','now','dec'] as $month) { $locwords[] = '/(' . preg_quote(mb_strtolower($this->gettext('long' . $month))) . '|' . preg_quote(mb_strtolower($this->gettext($month))) . ')\b/i'; $normwords[] = $month; $datewords[] = $month; } foreach (['on','this','next','at'] as $word) { $fillwords[] = preg_quote(mb_strtolower($this->gettext($word))); $fillwords[] = $word; } $raw = trim($rec['raw']); $date_str = ''; // translate localized keywords $raw = preg_replace('/^(' . implode('|', $fillwords) . ')\s*/i', '', $raw); $raw = preg_replace($locwords, $normwords, $raw); // find date pattern $date_pattern = '!^(\d+[./-]\s*)?((?:\d+[./-])|' . implode('|', $datewords) . ')\.?(\s+\d{4})?[:;,]?\s+!i'; if (preg_match($date_pattern, $raw, $m)) { $date_str .= $m[1] . $m[2] . $m[3]; $raw = preg_replace([$date_pattern, '/^(' . implode('|', $fillwords) . ')\s*/i'], '', $raw); // add year to date string if ($m[1] && !$m[3]) { $date_str .= date('Y'); } } // find time pattern $time_pattern = '/^(\d+([:.]\d+)?(\s*[hapm.]+)?),?\s+/i'; if (preg_match($time_pattern, $raw, $m)) { $has_time = true; $date_str .= ($date_str ? ' ' : 'today ') . $m[1]; $raw = preg_replace($time_pattern, '', $raw); } // yes, raw input matched a (valid) date if (strlen($date_str) && strtotime($date_str)) { $date = new DateTime($date_str, $this->timezone); $rec['date'] = $date->format('Y-m-d'); if (!empty($has_time)) { $rec['time'] = $date->format('H:i'); } $rec['title'] = $raw; } else { $rec['title'] = $rec['raw']; } } // normalize input from client if (isset($rec['complete'])) { $rec['complete'] = floatval($rec['complete']); if ($rec['complete'] > 1) { $rec['complete'] /= 100; } } if (isset($rec['flagged'])) { $rec['flagged'] = intval($rec['flagged']); } // fix for garbage input if ($rec['description'] == 'null') { $rec['description'] = ''; } foreach ($rec as $key => $val) { if ($val === 'null') { $rec[$key] = null; } } if (!empty($rec['date'])) { $this->normalize_dates($rec, 'date', 'time'); } if (!empty($rec['startdate'])) { $this->normalize_dates($rec, 'startdate', 'starttime'); } // convert tags to array, filter out empty entries if (isset($rec['tags']) && !is_array($rec['tags'])) { $rec['tags'] = array_filter((array)$rec['tags']); } // convert the submitted alarm values if (!empty($rec['valarms'])) { $valarms = []; foreach (libcalendaring::from_client_alarms($rec['valarms']) as $alarm) { // alarms can only work with a date (either task start, due or absolute alarm date) if (is_a($alarm['trigger'], 'DateTime') || $rec['date'] || $rec['startdate']) { $valarms[] = $alarm; } } $rec['valarms'] = $valarms; } // convert the submitted recurrence settings if (isset($rec['recurrence']) && is_array($rec['recurrence'])) { $refdate = null; if (!empty($rec['date'])) { $refdate = new DateTime($rec['date'] . ' ' . ($rec['time'] ?? ''), $this->timezone); } elseif (!empty($rec['startdate'])) { $refdate = new DateTime($rec['startdate'] . ' ' . ($rec['starttime'] ?? ''), $this->timezone); } if ($refdate) { $rec['recurrence'] = $this->lib->from_client_recurrence($rec['recurrence'], $refdate); // translate count into an absolute end date. // why? because when shifting completed tasks to the next occurrence, // the initial start date to count from gets lost. if (!empty($rec['recurrence']['COUNT'])) { $engine = libcalendaring::get_recurrence(); $engine->init($rec['recurrence'], $refdate); if ($until = $engine->end()) { $rec['recurrence']['UNTIL'] = $until; unset($rec['recurrence']['COUNT']); } } } else { // recurrence requires a reference date $rec['recurrence'] = ''; } } $handler = new kolab_attachments_handler(); $rec['attachments'] = $handler->attachments_set(self::SESSION_KEY, $rec['id'], $rec['attachments'] ?? []); // convert link references into simple URIs if (array_key_exists('links', $rec)) { $rec['links'] = array_map(function ($link) { return is_array($link) ? $link['uri'] : strval($link); }, (array)$rec['links']); } // convert invalid data if (isset($rec['attendees']) && !is_array($rec['attendees'])) { $rec['attendees'] = []; } if (!empty($rec['attendees'])) { foreach ((array) $rec['attendees'] as $i => $attendee) { if (isset($attendee['rsvp']) && is_string($attendee['rsvp'])) { $rec['attendees'][$i]['rsvp'] = $attendee['rsvp'] == 'true' || $attendee['rsvp'] == '1'; } } } // copy the task status to my attendee partstat if (!empty($rec['_reportpartstat'])) { if (($idx = $this->is_attendee($rec)) !== false) { if (!($rec['_reportpartstat'] == 'NEEDS-ACTION' && $rec['attendees'][$idx]['status'] == 'ACCEPTED')) { $rec['attendees'][$idx]['status'] = $rec['_reportpartstat']; } else { unset($rec['_reportpartstat']); } } } // set organizer from identity selector if ((isset($rec['_identity']) || (!empty($rec['attendees']) && empty($rec['organizer']))) && ($identity = $this->rc->user->get_identity($rec['_identity']))) { $rec['organizer'] = ['name' => $identity['name'], 'email' => $identity['email']]; } if (is_numeric($rec['id']) && $rec['id'] < 0) { unset($rec['id']); } return $rec; } /** * Utility method to convert a tasks date/time values into a normalized format */ private function normalize_dates(&$rec, $date_key, $time_key) { try { // parse date from user format (#2801) $date_format = $this->rc->config->get(empty($rec[$time_key]) ? 'date_format' : 'date_long', 'Y-m-d'); $date = DateTime::createFromFormat($date_format, trim(($rec[$date_key] ?? '') . ' ' . ($rec[$time_key] ?? '')), $this->timezone); // fall back to default strtotime logic if (empty($date)) { $date = new DateTime(($rec[$date_key] ?? '') . ' ' . ($rec[$time_key] ?? ''), $this->timezone); } $rec[$date_key] = $date->format('Y-m-d'); if (!empty($rec[$time_key])) { $rec[$time_key] = $date->format('H:i'); } return true; } catch (Exception $e) { $rec[$date_key] = $rec[$time_key] = null; } return false; } /** * Releases some resources after successful save */ private function cleanup_task(&$rec) { $handler = new kolab_attachments_handler(); $handler->attachments_cleanup(self::SESSION_KEY); } /** * When flagging a recurring task as complete, * clone it and shift dates to the next occurrence */ private function handle_recurrence(&$rec, $old) { $clone = null; if ($this->driver->is_complete($rec) && $old && !$this->driver->is_complete($old) && !empty($rec['recurrence'])) { $engine = libcalendaring::get_recurrence(); $rrule = $rec['recurrence']; $updates = []; // compute the next occurrence of date attributes foreach (['date' => 'time', 'startdate' => 'starttime'] as $date_key => $time_key) { if (empty($rec[$date_key])) { continue; } $date = new DateTime($rec[$date_key] . ' ' . ($rec[$time_key] ?? ''), $this->timezone); $engine->init($rrule, $date); if ($next = $engine->next_start()) { $updates[$date_key] = $next->format('Y-m-d'); if (!empty($rec[$time_key])) { $updates[$time_key] = $next->format('H:i'); } } } // shift absolute alarm dates if (!empty($updates) && is_array($rec['valarms'])) { $updates['valarms'] = []; unset($rrule['UNTIL'], $rrule['COUNT']); // make recurrence rule unlimited foreach ($rec['valarms'] as $i => $alarm) { if ($alarm['trigger'] instanceof DateTime) { $engine->init($rrule, $alarm['trigger']); if ($next = $engine->next_start()) { $alarm['trigger'] = $next; } } $updates['valarms'][$i] = $alarm; } } if (!empty($updates)) { // clone task to save a completed copy $clone = $rec; $clone['uid'] = $this->generate_uid(); $clone['parent_id'] = $rec['id']; unset($clone['id'], $clone['recurrence'], $clone['attachments']); // update the task but unset completed flag $rec = array_merge($rec, $updates); $rec['complete'] = $old['complete']; $rec['status'] = $old['status']; } } return $clone; } /** * Send out an invitation/notification to all task attendees */ private function notify_attendees($task, $old, $action = 'edit', $comment = null) { $is_cancelled = false; if ($action == 'delete' || ($task['status'] == 'CANCELLED' && $old['status'] != $task['status'])) { $task['cancelled'] = true; $is_cancelled = true; } $itip = $this->load_itip(); $emails = $this->lib->get_user_emails(); $itip_notify = (int)$this->rc->config->get('calendar_itip_send_option', 3); // add comment to the iTip attachment $task['comment'] = $comment; // needed to generate VTODO instead of VEVENT entry $task['_type'] = 'task'; // compose multipart message using PEAR:Mail_Mime $method = $action == 'delete' ? 'CANCEL' : 'REQUEST'; $object = $this->to_libcal($task); $message = $itip->compose_itip_message($object, $method, $task['sequence'] > $old['sequence']); // list existing attendees from the $old task $old_attendees = []; foreach ((array)$old['attendees'] as $attendee) { $old_attendees[] = $attendee['email']; } // send to every attendee $sent = 0; $current = []; foreach ((array)$task['attendees'] as $attendee) { $current[] = strtolower($attendee['email']); // skip myself for obvious reasons if (!$attendee['email'] || in_array(strtolower($attendee['email']), $emails)) { continue; } // skip if notification is disabled for this attendee if ($attendee['noreply'] && $itip_notify & 2) { continue; } // skip if this attendee has delegated and set RSVP=FALSE if ($attendee['status'] == 'DELEGATED' && $attendee['rsvp'] === false) { continue; } // which template to use for mail text $is_new = !in_array($attendee['email'], $old_attendees); $is_rsvp = $is_new || $task['sequence'] > $old['sequence']; $bodytext = $is_cancelled ? 'itipcancelmailbody' : ($is_new ? 'invitationmailbody' : 'itipupdatemailbody'); $subject = $is_cancelled ? 'itipcancelsubject' : ($is_new ? 'invitationsubject' : ($task['title'] ? 'itipupdatesubject' : 'itipupdatesubjectempty')); // finally send the message if ($itip->send_itip_message($object, $method, $attendee, $subject, $bodytext, $message, $is_rsvp)) { $sent++; } else { $sent = -100; } } // send CANCEL message to removed attendees foreach ((array)$old['attendees'] as $attendee) { if (!$attendee['email'] || in_array(strtolower($attendee['email']), $current)) { continue; } $vtodo = $this->to_libcal($old); $vtodo['cancelled'] = $is_cancelled; $vtodo['attendees'] = [$attendee]; $vtodo['comment'] = $comment; if ($itip->send_itip_message($vtodo, 'CANCEL', $attendee, 'itipcancelsubject', 'itipcancelmailbody')) { $sent++; } else { $sent = -100; } } return $sent; } /** * Compare two task objects and return differing properties * * @param array $a Event A * @param array $b Event B * * @return array List of differing task properties */ public static function task_diff($a, $b) { $diff = []; $ignore = ['changed' => 1, 'attachments' => 1]; foreach (array_unique(array_merge(array_keys($a), array_keys($b))) as $key) { if (empty($ignore[$key]) && $a[$key] != $b[$key]) { $diff[] = $key; } } // only compare number of attachments if (count($a['attachments']) != count($b['attachments'])) { $diff[] = 'attachments'; } return $diff; } /** * Dispatcher for tasklist actions initiated by the client */ public function tasklist_action() { $action = rcube_utils::get_input_value('action', rcube_utils::INPUT_GPC); $list = rcube_utils::get_input_value('l', rcube_utils::INPUT_GPC, true); $success = false; $jsenv = []; unset($list['_token']); switch ($action) { case 'form-new': case 'form-edit': $this->load_ui(); echo $this->ui->tasklist_editform($action, $list); exit; case 'new': $list += ['showalarms' => !empty($list['showalarms']), 'active' => true, 'editable' => true]; if ($insert_id = $this->driver->create_list($list)) { $list['id'] = $insert_id; if (empty($list['_reload'])) { $this->load_ui(); $list['html'] = $this->ui->tasklist_list_item($insert_id, $list, $jsenv); $list += $jsenv[$insert_id] ?? []; // @phpstan-ignore-line } $this->rc->output->command('plugin.insert_tasklist', $list); $success = true; } break; case 'edit': $list['oldid'] = $list['id']; $list['showalarms'] = !empty($list['showalarms']); if ($success = $this->driver->edit_list($list)) { $this->rc->output->command('plugin.update_tasklist', $list); } break; case 'subscribe': $success = $this->driver->subscribe_list($list); break; case 'delete': if (($success = $this->driver->delete_list($list))) { $this->rc->output->command('plugin.destroy_tasklist', $list); } break; case 'search': $this->load_ui(); $results = []; $query = rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC); $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC); foreach ((array)$this->driver->search_lists($query, $source) as $id => $prop) { $editname = $prop['editname']; unset($prop['editname']); // force full name to be displayed $prop['active'] = false; // let the UI generate HTML and CSS representation for this calendar $html = $this->ui->tasklist_list_item($id, $prop, $jsenv); $prop += $jsenv[$id] ?? []; // @phpstan-ignore-line $prop['editname'] = $editname; $prop['html'] = $html; $results[] = $prop; } // report more results available if (!empty($this->driver->search_more_results)) { $this->rc->output->show_message('autocompletemore', 'notice'); } $this->rc->output->command('multi_thread_http_response', $results, rcube_utils::get_input_value('_reqid', rcube_utils::INPUT_GPC)); return; } if ($success) { $this->rc->output->show_message('successfullysaved', 'confirmation'); } else { $this->rc->output->show_message('tasklist.errorsaving', 'error'); } $this->rc->output->command('plugin.unlock_saving'); } /** * Get counts for active tasks divided into different selectors */ public function fetch_counts() { if (isset($_REQUEST['lists'])) { $lists = rcube_utils::get_input_value('lists', rcube_utils::INPUT_GPC); } else { $lists = []; foreach ($this->driver->get_lists() as $list) { if (!empty($list['active'])) { $lists[] = $list['id']; } } } $counts = $this->driver->count_tasks($lists); $this->rc->output->command('plugin.update_counts', $counts); } /** * Adjust the cached counts after changing a task */ public function update_counts($oldrec, $newrec) { // rebuild counts until this function is finally implemented $this->fetch_counts(); // $this->rc->output->command('plugin.update_counts', $counts); } /** * */ public function fetch_tasks() { $mask = intval(rcube_utils::get_input_value('filter', rcube_utils::INPUT_GPC)); $search = rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC); $lists = rcube_utils::get_input_value('lists', rcube_utils::INPUT_GPC); $filter = ['mask' => $mask, 'search' => $search]; $data = $this->tasks_data($this->driver->list_tasks($filter, $lists)); $this->rc->output->command('plugin.data_ready', [ 'filter' => $mask, 'lists' => $lists, 'search' => $search, 'data' => $data, 'tags' => $this->driver->get_tags(), ]); } /** * Handler for printing calendars */ public function print_tasks() { // Add CSS stylesheets to the page header $skin_path = $this->local_skin_path(); $this->include_stylesheet($skin_path . '/print.css'); $this->include_script('tasklist.js'); $this->rc->output->add_handlers([ 'plugin.tasklist_print' => [$this, 'print_tasks_list'], ]); $this->rc->output->set_pagetitle($this->gettext('print')); $this->rc->output->send('tasklist.print'); } /** * Handler for printing calendars */ public function print_tasks_list($attrib) { $mask = intval(rcube_utils::get_input_value('filter', rcube_utils::INPUT_GPC)); $search = rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC); $lists = rcube_utils::get_input_value('lists', rcube_utils::INPUT_GPC); $filter = ['mask' => $mask, 'search' => $search]; $data = $this->tasks_data($this->driver->list_tasks($filter, $lists)); // we'll build the tasks table in javascript on page load // where we have sorting methods, etc. $this->rc->output->set_env('tasks', $data); $this->rc->output->set_env('filtermask', $mask); return $this->ui->tasks_resultview($attrib); } /** * Prepare and sort the given task records to be sent to the client */ private function tasks_data($records) { $data = $this->task_tree = $this->task_titles = []; foreach ($records as $rec) { if (!empty($rec['parent_id'])) { $this->task_tree[$rec['id']] = $rec['parent_id']; } $this->encode_task($rec); $data[] = $rec; } // assign hierarchy level indicators for later sorting array_walk($data, [$this, 'task_walk_tree']); return $data; } /** * Prepare the given task record before sending it to the client */ private function encode_task(&$rec) { $rec['mask'] = $this->filter_mask($rec); $rec['flagged'] = intval($rec['flagged'] ?? 0); $rec['complete'] = floatval($rec['complete'] ?? 0); if (!empty($rec['created']) && is_object($rec['created'])) { $rec['created_'] = $this->rc->format_date($rec['created']); $rec['created'] = $rec['created']->format('U'); } if (!empty($rec['changed']) && is_object($rec['changed'])) { $rec['changed_'] = $this->rc->format_date($rec['changed']); $rec['changed'] = $rec['changed']->format('U'); } else { $rec['changed'] = null; } if (!empty($rec['date'])) { try { $date = new DateTime($rec['date'] . ' ' . ($rec['time'] ?? ''), $this->timezone); $rec['datetime'] = intval($date->format('U')); $rec['date'] = $date->format($this->rc->config->get('date_format', 'Y-m-d')); $rec['_hasdate'] = 1; } catch (Exception $e) { $rec['date'] = $rec['datetime'] = null; } } else { $rec['date'] = $rec['datetime'] = null; $rec['_hasdate'] = 0; } if (!empty($rec['startdate'])) { try { $date = new DateTime($rec['startdate'] . ' ' . ($rec['starttime'] ?? ''), $this->timezone); $rec['startdatetime'] = intval($date->format('U')); $rec['startdate'] = $date->format($this->rc->config->get('date_format', 'Y-m-d')); } catch (Exception $e) { $rec['startdate'] = $rec['startdatetime'] = null; } } if (!empty($rec['valarms'])) { $rec['alarms_text'] = libcalendaring::alarms_text($rec['valarms']); $rec['valarms'] = libcalendaring::to_client_alarms($rec['valarms']); } if (!empty($rec['recurrence'])) { $rec['recurrence_text'] = $this->lib->recurrence_text($rec['recurrence']); $rec['recurrence'] = $this->lib->to_client_recurrence($rec['recurrence'], !empty($rec['time']) || !empty($rec['starttime'])); } if (!empty($rec['attachments'])) { foreach ((array) $rec['attachments'] as $k => $attachment) { $rec['attachments'][$k]['classname'] = rcube_utils::file2class($attachment['mimetype'], $attachment['name']); unset($rec['attachments'][$k]['data']); } } // convert link URIs references into structs if (array_key_exists('links', $rec)) { foreach ((array) $rec['links'] as $i => $link) { if (strpos($link, 'imap://') === 0 && ($msgref = $this->driver->get_message_reference($link, 'task'))) { $rec['links'][$i] = $msgref; } } } // Convert HTML description into plain text if ($this->is_html($rec)) { $h2t = new rcube_html2text($rec['description'], false, true, 0); $rec['description'] = $h2t->get_text(); } if (!isset($rec['tags']) || !is_array($rec['tags'])) { $rec['tags'] = []; } sort($rec['tags'], SORT_LOCALE_STRING); if (in_array($rec['id'], $this->collapsed_tasks)) { $rec['collapsed'] = true; } if (empty($rec['parent_id'])) { $rec['parent_id'] = null; } $this->task_titles[$rec['id']] = $rec['title'] ?? ''; } /** * Determine whether the given task description is HTML formatted */ private function is_html($task) { // check for opening and closing or tags return isset($task['description']) && preg_match('/<(html|body)(\s+[a-z]|>)/', $task['description'], $m) && strpos($task['description'], '') > 0; } /** * Callback function for array_walk over all tasks. * Sets tree depth and parent titles */ private function task_walk_tree(&$rec) { $rec['_depth'] = 0; $parent_titles = []; $parent_id = $this->task_tree[$rec['id']] ?? null; while ($parent_id) { $rec['_depth']++; if (isset($this->task_titles[$parent_id])) { array_unshift($parent_titles, $this->task_titles[$parent_id]); } $parent_id = $this->task_tree[$parent_id] ?? null; } if (count($parent_titles)) { $rec['parent_title'] = implode(' ยป ', array_filter($parent_titles)); } } /** * Compute the filter mask of the given task * * @param array $rec Hash array with Task record properties * * @return int Filter mask */ public function filter_mask($rec) { static $today, $today_date, $tomorrow, $weeklimit; if (!$today) { $today_date = new libcalendaring_datetime('now', $this->timezone); $today = $today_date->format('Y-m-d'); $tomorrow_date = new libcalendaring_datetime('now + 1 day', $this->timezone); $tomorrow = $tomorrow_date->format('Y-m-d'); // In Kolab-mode we hide "Next 7 days" filter, which means // "Later" should catch tasks with date after tomorrow (#5353) if ($this->rc->output->get_env('tasklist_driver') == 'kolab') { $weeklimit = $tomorrow; } else { $week_date = new DateTime('now + 7 days', $this->timezone); $weeklimit = $week_date->format('Y-m-d'); } } $mask = 0; $start = !empty($rec['startdate']) ? $rec['startdate'] : '1900-00-00'; $duedate = !empty($rec['date']) ? $rec['date'] : '3000-00-00'; if (!empty($rec['flagged'])) { $mask |= self::FILTER_MASK_FLAGGED; } if ($this->driver->is_complete($rec)) { $mask |= self::FILTER_MASK_COMPLETE; } if (empty($rec['date'])) { $mask |= self::FILTER_MASK_NODATE; } elseif ($rec['date'] < $today) { $mask |= self::FILTER_MASK_OVERDUE; } if (empty($rec['recurrence']) || $duedate < $today || $start > $weeklimit) { if ($duedate <= $today || (!empty($rec['startdate']) && $start <= $today)) { $mask |= self::FILTER_MASK_TODAY; } elseif (($start > $today && $start <= $tomorrow) || ($duedate > $today && $duedate <= $tomorrow)) { $mask |= self::FILTER_MASK_TOMORROW; } elseif (($start > $tomorrow && $start <= $weeklimit) || ($duedate > $tomorrow && $duedate <= $weeklimit)) { $mask |= self::FILTER_MASK_WEEK; } elseif ($start > $weeklimit || $duedate > $weeklimit) { $mask |= self::FILTER_MASK_LATER; } } elseif (!empty($rec['startdate']) || !empty($rec['date'])) { $date = new libcalendaring_datetime(!empty($rec['startdate']) ? $rec['startdate'] : $rec['date'], $this->timezone); // set safe recurrence start while ($date->format('Y-m-d') >= $today) { switch ($rec['recurrence']['FREQ']) { case 'DAILY': $date = clone $today_date; $date->sub(new DateInterval('P1D')); break; case 'WEEKLY': $date->sub(new DateInterval('P7D')); break; case 'MONTHLY': $date->sub(new DateInterval('P1M')); break; case 'YEARLY': $date->sub(new DateInterval('P1Y')); break; default: break 2; } } $date->_dateonly = true; $engine = libcalendaring::get_recurrence(); $engine->init($rec['recurrence'], $date); // check task occurrences (stop next week) // FIXME: is there a faster way of doing this? while ($date = $engine->next_start()) { $date = $date->format('Y-m-d'); // break iteration asap if ($date > $duedate || ($mask & self::FILTER_MASK_LATER)) { break; } if ($date == $today) { $mask |= self::FILTER_MASK_TODAY; } elseif ($date == $tomorrow) { $mask |= self::FILTER_MASK_TOMORROW; } elseif ($date > $tomorrow && $date <= $weeklimit) { $mask |= self::FILTER_MASK_WEEK; } elseif ($date > $weeklimit) { $mask |= self::FILTER_MASK_LATER; break; } } } // add masks for assigned tasks if ($this->is_organizer($rec) && !empty($rec['attendees']) && $this->is_attendee($rec) === false) { $mask |= self::FILTER_MASK_ASSIGNED; } elseif (/*empty($rec['attendees']) ||*/ $this->is_attendee($rec) !== false) { $mask |= self::FILTER_MASK_MYTASKS; } return $mask; } /** * Determine whether the current user is an attendee of the given task */ public function is_attendee($task) { $emails = $this->lib->get_user_emails(); foreach ((array) ($task['attendees'] ?? []) as $i => $attendee) { if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { return $i; } } return false; } /** * Determine whether the current user is the organizer of the given task */ public function is_organizer($task) { $emails = $this->lib->get_user_emails(); return (empty($task['organizer']) || in_array(strtolower($task['organizer']['email']), $emails)); } /** * Handle invitations to a shared folder */ public function share_invitation() { $id = rcube_utils::get_input_value('id', rcube_utils::INPUT_POST); $invitation = rcube_utils::get_input_value('invitation', rcube_utils::INPUT_POST); if ($tasklist = $this->driver->accept_share_invitation($invitation)) { $this->rc->output->command('plugin.share-invitation', ['id' => $id, 'source' => $tasklist]); } } /******* UI functions ********/ /** * Render main view of the tasklist task */ public function tasklist_view() { $this->ui->init(); $this->ui->init_templates(); // set autocompletion env $this->rc->output->set_env('autocomplete_threads', (int)$this->rc->config->get('autocomplete_threads', 0)); $this->rc->output->set_env('autocomplete_max', (int)$this->rc->config->get('autocomplete_max', 15)); $this->rc->output->set_env('autocomplete_min_length', $this->rc->config->get('autocomplete_min_length')); $this->rc->output->add_label('autocompletechars', 'autocompletemore', 'delete', 'close'); $this->rc->output->set_pagetitle($this->gettext('navtitle')); $this->rc->output->send('tasklist.mainview'); } /** * Handler for keep-alive requests * This will check for updated data in active lists and sync them to the client */ public function refresh($attr) { // refresh the entire list every 10th time to also sync deleted items if (rand(0, 10) == 10) { $this->rc->output->command('plugin.reload_data'); return; } $filter = [ 'since' => $attr['last'], 'search' => rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC), 'mask' => intval(rcube_utils::get_input_value('filter', rcube_utils::INPUT_GPC)) & self::FILTER_MASK_COMPLETE, ]; $lists = rcube_utils::get_input_value('lists', rcube_utils::INPUT_GPC); ; $updates = $this->driver->list_tasks($filter, $lists); if (!empty($updates)) { $this->rc->output->command('plugin.refresh_tasks', $this->tasks_data($updates), true); // update counts $counts = $this->driver->count_tasks($lists); $this->rc->output->command('plugin.update_counts', $counts); } } /** * Handler for pending_alarms plugin hook triggered by the calendar module on keep-alive requests. * This will check for pending notifications and pass them to the client */ public function pending_alarms($p) { $this->load_driver(); + if ($alarms = $this->driver->pending_alarms($p['time'] ?: time())) { foreach ($alarms as $alarm) { // encode alarm object to suit the expectations of the calendaring code if ($alarm['date']) { $alarm['start'] = new DateTime($alarm['date'] . ' ' . $alarm['time'], $this->timezone); } $alarm['id'] = 'task:' . $alarm['id']; // prefix ID with task: $alarm['allday'] = empty($alarm['time']) ? 1 : 0; $p['alarms'][] = $alarm; } } return $p; } /** * Handler for alarm dismiss hook triggered by the calendar module */ public function dismiss_alarms($p) { $this->load_driver(); + foreach ((array)$p['ids'] as $id) { if (strpos($id, 'task:') === 0) { $p['success'] |= $this->driver->dismiss_alarm(substr($id, 5), $p['snooze']); } } return $p; } /** * Handler for importing .ics files */ public function import_tasks() { // Upload progress update if (!empty($_GET['_progress'])) { $this->rc->upload_progress(); } @set_time_limit(0); // process uploaded file if there is no error $err = $_FILES['_data']['error']; if (!$err && $_FILES['_data']['tmp_name']) { $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC); $lists = $this->driver->get_lists(); $list = $lists[$source] ?: $this->get_default_tasklist(); $source = $list['id']; $errors = 0; // extract zip file if ($_FILES['_data']['type'] == 'application/zip') { $count = 0; if (class_exists('ZipArchive', false)) { $zip = new ZipArchive(); if ($zip->open($_FILES['_data']['tmp_name'])) { $randname = uniqid('zip-' . session_id(), true); $tmpdir = slashify($this->rc->config->get('temp_dir', sys_get_temp_dir())) . $randname; mkdir($tmpdir, 0700); // extract each ical file from the archive and import it for ($i = 0; $i < $zip->numFiles; $i++) { $filename = $zip->getNameIndex($i); if (preg_match('/\.ics$/i', $filename)) { $tmpfile = $tmpdir . '/' . basename($filename); if (copy('zip://' . $_FILES['_data']['tmp_name'] . '#' . $filename, $tmpfile)) { $count += $this->import_from_file($tmpfile, $source, $errors); unlink($tmpfile); } } } rmdir($tmpdir); $zip->close(); } else { $errors = 1; $msg = 'Failed to open zip file.'; } } else { $errors = 1; $msg = 'Zip files are not supported for import.'; } } else { // attempt to import the uploaded file directly $count = $this->import_from_file($_FILES['_data']['tmp_name'], $source, $errors); } if ($count) { $this->rc->output->command('display_message', $this->gettext(['name' => 'importsuccess', 'vars' => ['nr' => $count]]), 'confirmation'); $this->rc->output->command('plugin.import_success', ['source' => $source, 'refetch' => true]); } elseif (!$errors) { $this->rc->output->command('display_message', $this->gettext('importnone'), 'notice'); $this->rc->output->command('plugin.import_success', ['source' => $source]); } else { $this->rc->output->command('plugin.import_error', ['message' => $this->gettext('importerror') . (!empty($msg) ? ': ' . $msg : '')]); } } else { if ($err == UPLOAD_ERR_INI_SIZE || $err == UPLOAD_ERR_FORM_SIZE) { $msg = $this->rc->gettext(['name' => 'filesizeerror', 'vars' => [ 'size' => $this->rc->show_bytes(parse_bytes(ini_get('upload_max_filesize')))]]); } else { $msg = $this->rc->gettext('fileuploaderror'); } $this->rc->output->command('plugin.import_error', ['message' => $msg]); } $this->rc->output->send('iframe'); } /** * Helper function to parse and import a single .ics file */ private function import_from_file($filepath, $source, &$errors) { $user_email = $this->rc->user->get_username(); $ical = $this->get_ical(); $errors = !$ical->fopen($filepath); $count = $i = 0; foreach ($ical as $task) { // keep the browser connection alive on long import jobs if (++$i > 100 && $i % 100 == 0) { echo ""; ob_flush(); } if ($task['_type'] == 'task') { $task['list'] = $source; if ($this->driver->create_task($task)) { $count++; } else { $errors++; } } } return $count; } /** * Construct the ics file for exporting tasks to iCalendar format */ public function export_tasks() { $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC); $task_id = rcube_utils::get_input_value('id', rcube_utils::INPUT_GPC); $attachments = (bool) rcube_utils::get_input_value('attachments', rcube_utils::INPUT_GPC); $this->load_driver(); $browser = new rcube_browser(); $lists = $this->driver->get_lists(); $tasks = []; $filter = []; // get message UIDs for filter if ($source && ($list = $lists[$source])) { $filename = html_entity_decode($list['name']) ?: $source; $filter = [$source => true]; } elseif ($task_id) { $filename = 'tasks'; foreach (explode(',', $task_id) as $id) { [$list_id, $task_id] = explode(':', $id, 2); if ($list_id && $task_id) { $filter[$list_id][] = $task_id; } } } // Get tasks foreach ($filter as $list_id => $uids) { $_filter = is_array($uids) ? ['uid' => $uids] : null; $_tasks = $this->driver->list_tasks($_filter, $list_id); if (!empty($_tasks)) { $tasks = array_merge($tasks, $_tasks); } } // Set file name $filename = 'tasks'; if ($source && count($tasks) == 1) { $filename = $tasks[0]['title'] ?: 'task'; } $filename .= '.ics'; $filename = $browser->ie ? rawurlencode($filename) : addcslashes($filename, '"'); $tasks = array_map([$this, 'to_libcal'], $tasks); // Give plugins a possibility to implement other output formats or modify the result $plugin = $this->rc->plugins->exec_hook('tasks_export', [ 'result' => $tasks, 'attachments' => $attachments, 'filename' => $filename, 'plugin' => $this, ]); if ($plugin['abort']) { exit; } $this->rc->output->nocacheing_headers(); // don't kill the connection if download takes more than 30 sec. @set_time_limit(0); header("Content-Type: text/calendar"); header("Content-Disposition: inline; filename=\"" . $plugin['filename'] . "\""); $this->get_ical()->export( $plugin['result'], '', true, !empty($plugin['attachments']) ? [$this->driver, 'get_attachment_body'] : null ); exit; } /******* Attachment handling *******/ /** * Handler for attachments upload */ public function attachment_upload() { $handler = new kolab_attachments_handler(); $handler->attachment_upload(self::SESSION_KEY); } /** * Handler for attachments download/displaying */ public function attachment_get() { $handler = new kolab_attachments_handler(); // show loading page if (!empty($_GET['_preload'])) { return $handler->attachment_loading_page(); } $task = rcube_utils::get_input_value('_t', rcube_utils::INPUT_GPC); $list = rcube_utils::get_input_value('_list', rcube_utils::INPUT_GPC); $id = rcube_utils::get_input_value('_id', rcube_utils::INPUT_GPC); $rev = rcube_utils::get_input_value('_rev', rcube_utils::INPUT_GPC); $task = ['id' => $task, 'list' => $list, 'rev' => $rev]; $attachment = $this->driver->get_attachment($id, $task); // show part page if (!empty($_GET['_frame'])) { $handler->attachment_page($attachment); } // deliver attachment content elseif ($attachment) { if (empty($attachment['body'])) { $attachment['body'] = $this->driver->get_attachment_body($id, $task); } $handler->attachment_get($attachment); } // if we arrive here, the requested part was not found header('HTTP/1.1 404 Not Found'); exit; } /******* Email related function *******/ public function mail_message2task() { $this->load_ui(); $this->ui->init(); $this->ui->init_templates(); $this->ui->tasklists(); $uid = (int) rcube_utils::get_input_value('_uid', rcube_utils::INPUT_GET); $mbox = rcube_utils::get_input_value('_mbox', rcube_utils::INPUT_GET); $task = []; $imap = $this->rc->get_storage(); $message = new rcube_message($uid, $mbox); if ($message->headers) { $task['title'] = trim($message->subject); $task['description'] = trim($message->first_text_part()); $task['id'] = -$uid; $this->load_driver(); // add a reference to the email message if ($msgref = $this->driver->get_message_reference($message->headers, $mbox)) { $task['links'] = [$msgref]; } // copy mail attachments to task elseif (!empty($message->attachments) && $this->driver->attachments) { $handler = new kolab_attachments_handler(); $task['attachments'] = $handler->copy_mail_attachments(self::SESSION_KEY, $task['id'], $message); } $this->rc->output->set_env('task_prop', $task); } else { $this->rc->output->command('display_message', $this->gettext('messageopenerror'), 'error'); } $this->rc->output->send('tasklist.dialog'); } /** * Add UI element to copy task invitations or updates to the tasklist */ public function mail_messagebody_html($p) { // load iCalendar functions (if necessary) if (!empty($this->lib->ical_parts)) { $this->get_ical(); $this->load_itip(); } $html = ''; $has_tasks = false; $ical_objects = $this->lib->get_mail_ical_objects(); // show a box for every task in the file foreach ($ical_objects as $idx => $task) { if ($task['_type'] != 'task') { continue; } $has_tasks = true; // get prepared inline UI for this event object if ($ical_objects->method) { $html .= html::div( 'tasklist-invitebox invitebox boxinformation', $this->itip->mail_itip_inline_ui( $task, $ical_objects->method, $ical_objects->mime_id . ':' . $idx, 'tasks', rcube_utils::anytodatetime($ical_objects->message_date) ) ); } // limit listing if ($idx >= 3) { break; } } // list linked tasks $links = []; foreach ($this->message_tasks as $task) { $checkbox = new html_checkbox([ 'name' => 'completed', 'class' => 'complete pretty-checkbox', 'title' => $this->gettext('complete'), 'data-list' => $task['list'], ]); $complete = $this->driver->is_complete($task); $links[] = html::tag( 'li', 'messagetaskref' . ($complete ? ' complete' : ''), $checkbox->show($complete ? $task['id'] : null, ['value' => $task['id']]) . ' ' . html::a([ 'href' => $this->rc->url([ 'task' => 'tasks', 'list' => $task['list'], 'id' => $task['id'], ]), 'class' => 'messagetasklink', 'rel' => $task['id'] . '@' . $task['list'], 'target' => '_blank', ], rcube::Q($task['title'])) ); } if (count($links)) { $html .= html::div('messagetasklinks boxinformation', html::tag('ul', 'tasklist', implode("\n", $links))); } // prepend iTip/relation boxes to message body if ($html) { $this->load_ui(); $this->ui->init(); $p['content'] = $html . $p['content']; $this->rc->output->add_label('tasklist.savingdata', 'tasklist.deletetaskconfirm', 'tasklist.declinedeleteconfirm'); } // add "Save to tasks" button into attachment menu if ($has_tasks) { $this->add_button([ 'id' => 'attachmentsavetask', 'name' => 'attachmentsavetask', 'type' => 'link', 'wrapper' => 'li', 'command' => 'attachment-save-task', 'class' => 'icon tasklistlink disabled', 'classact' => 'icon tasklistlink active', 'innerclass' => 'icon taskadd', 'label' => 'tasklist.savetotasklist', ], 'attachmentmenu'); } return $p; } /** * Lookup backend storage and find notes associated with the given message */ public function mail_message_load($p) { if (empty($p['object']->headers->others['x-kolab-type'])) { $this->load_driver(); $this->message_tasks = $this->driver->get_message_related_tasks($p['object']->headers, $p['object']->folder); // sort message tasks by completeness and due date $driver = $this->driver; array_walk($this->message_tasks, [$this, 'encode_task']); usort($this->message_tasks, function ($a, $b) use ($driver) { $a_complete = intval($driver->is_complete($a)); $b_complete = intval($driver->is_complete($b)); $d = $a_complete - $b_complete; if (!$d) { $d = $b['_hasdate'] - $a['_hasdate']; } if (!$d) { $d = $a['datetime'] - $b['datetime']; } return $d; }); } } /** * Load iCalendar functions */ public function get_ical() { if (empty($this->ical)) { $this->ical = libcalendaring::get_ical(); } return $this->ical; } /** * Get properties of the tasklist this user has specified as default */ public function get_default_tasklist($lists = null) { if ($lists === null) { $lists = $this->driver->get_lists(tasklist_driver::FILTER_PERSONAL | tasklist_driver::FILTER_WRITEABLE); } $list = null; $first = null; foreach ($lists as $l) { if ($l['default']) { $list = $l; } if ($l['editable']) { $first = $l; } } return $list ?: $first; } /** * Import the full payload from a mail message attachment */ public function mail_import_attachment() { $uid = rcube_utils::get_input_value('_uid', rcube_utils::INPUT_POST); $mbox = rcube_utils::get_input_value('_mbox', rcube_utils::INPUT_POST); $mime_id = rcube_utils::get_input_value('_part', rcube_utils::INPUT_POST); $charset = RCUBE_CHARSET; // establish imap connection $imap = $this->rc->get_storage(); $imap->set_folder($mbox); if ($uid && $mime_id) { $part = $imap->get_message_part($uid, $mime_id); // $headers = $imap->get_message_headers($uid); if ($part->ctype_parameters['charset']) { $charset = $part->ctype_parameters['charset']; } if ($part) { $tasks = $this->get_ical()->import($part, $charset); } } $success = $existing = 0; if (!empty($tasks)) { // find writeable tasklist to store task $cal_id = !empty($_REQUEST['_list']) ? rcube_utils::get_input_value('_list', rcube_utils::INPUT_POST) : null; $lists = $this->driver->get_lists(); foreach ($tasks as $task) { // save to tasklist $list = $lists[$cal_id] ?: $this->get_default_tasklist(); if ($list && $list['editable'] && $task['_type'] == 'task') { $task = $this->from_ical($task); $task['list'] = $list['id']; if (!$this->driver->get_task($task['uid'])) { $success += (bool) $this->driver->create_task($task); } else { $existing++; } } } } if ($success) { $this->rc->output->command('display_message', $this->gettext([ 'name' => 'importsuccess', 'vars' => ['nr' => $success], ]), 'confirmation'); } elseif ($existing) { $this->rc->output->command('display_message', $this->gettext('importwarningexists'), 'warning'); } else { $this->rc->output->command('display_message', $this->gettext('errorimportingtask'), 'error'); } } /** * Handler for POST request to import an event attached to a mail message */ public function mail_import_itip() { $uid = rcube_utils::get_input_value('_uid', rcube_utils::INPUT_POST); $mbox = rcube_utils::get_input_value('_mbox', rcube_utils::INPUT_POST); $mime_id = rcube_utils::get_input_value('_part', rcube_utils::INPUT_POST); $status = rcube_utils::get_input_value('_status', rcube_utils::INPUT_POST); $comment = rcube_utils::get_input_value('_comment', rcube_utils::INPUT_POST); $delete = intval(rcube_utils::get_input_value('_del', rcube_utils::INPUT_POST)); $noreply = intval(rcube_utils::get_input_value('_noreply', rcube_utils::INPUT_POST)) || $status == 'needs-action'; $error_msg = $this->gettext('errorimportingtask'); $success = false; $reply_sender = null; $organizer = null; $delegate = null; $existing = null; $deleted = null; $dontsave = false; $metadata = []; if ($status == 'delegated') { $delegates = rcube_mime::decode_address_list(rcube_utils::get_input_value('_to', rcube_utils::INPUT_POST, true), 1, false); $delegate = reset($delegates); if (empty($delegate) || empty($delegate['mailto'])) { $this->rc->output->command('display_message', $this->gettext('libcalendaring.delegateinvalidaddress'), 'error'); return; } } // successfully parsed tasks? if ($task = $this->lib->mail_get_itip_object($mbox, $uid, $mime_id, 'task')) { $task = $this->from_ical($task); // forward iTip request to delegatee if ($delegate) { $rsvpme = rcube_utils::get_input_value('_rsvp', rcube_utils::INPUT_POST); $itip = $this->load_itip(); $task['comment'] = $comment; if ($itip->delegate_to($task, $delegate, !empty($rsvpme))) { $this->rc->output->show_message('tasklist.itipsendsuccess', 'confirmation'); } else { $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error'); } unset($task['comment']); } $mode = tasklist_driver::FILTER_PERSONAL | tasklist_driver::FILTER_SHARED | tasklist_driver::FILTER_WRITEABLE; // find writeable list to store the task $list_id = !empty($_REQUEST['_folder']) ? rcube_utils::get_input_value('_folder', rcube_utils::INPUT_POST) : null; $lists = $this->driver->get_lists($mode); $list = $lists[$list_id] ?? null; $dontsave = $_REQUEST['_folder'] === '' && $task['_method'] == 'REQUEST'; // select default list except user explicitly selected 'none' if (!$list && !$dontsave) { $list = $this->get_default_tasklist($lists); } $metadata = [ 'uid' => $task['uid'], 'changed' => is_object($task['changed']) ? $task['changed']->format('U') : 0, 'sequence' => intval($task['sequence']), 'fallback' => strtoupper($status), 'method' => $task['_method'], 'task' => 'tasks', ]; // update my attendee status according to submitted method if (!empty($status)) { $organizer = $task['organizer']; $emails = $this->lib->get_user_emails(); foreach ($task['attendees'] as $i => $attendee) { if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { $metadata['attendee'] = $attendee['email']; $metadata['rsvp'] = $attendee['role'] != 'NON-PARTICIPANT'; $reply_sender = $attendee['email']; $task['attendees'][$i]['status'] = strtoupper($status); if (!in_array($task['attendees'][$i]['status'], ['NEEDS-ACTION','DELEGATED'])) { $task['attendees'][$i]['rsvp'] = false; // unset RSVP attribute } } } // add attendee with this user's default identity if not listed if (!$reply_sender) { $sender_identity = $this->rc->user->list_emails(true); $task['attendees'][] = [ 'name' => $sender_identity['name'], 'email' => $sender_identity['email'], 'role' => 'OPT-PARTICIPANT', 'status' => strtoupper($status), ]; $metadata['attendee'] = $sender_identity['email']; } } // save to tasklist if ($list && $list['editable']) { $task['list'] = $list['id']; // check for existing task with the same UID $existing = $this->find_task($task['uid'], $mode); if ($existing) { // only update attendee status if ($task['_method'] == 'REPLY') { // try to identify the attendee using the email sender address $existing_attendee = -1; $existing_attendee_emails = []; foreach ($existing['attendees'] as $i => $attendee) { $existing_attendee_emails[] = $attendee['email']; if ($task['_sender'] && ($attendee['email'] == $task['_sender'] || $attendee['email'] == $task['_sender_utf'])) { $existing_attendee = $i; } } $task_attendee = null; foreach ($task['attendees'] as $attendee) { if ($task['_sender'] && ($attendee['email'] == $task['_sender'] || $attendee['email'] == $task['_sender_utf'])) { $task_attendee = $attendee; $metadata['fallback'] = $attendee['status']; $metadata['attendee'] = $attendee['email']; $metadata['rsvp'] = $attendee['rsvp'] || $attendee['role'] != 'NON-PARTICIPANT'; if ($attendee['status'] != 'DELEGATED') { break; } } // also copy delegate attendee elseif (!empty($attendee['delegated-from']) && (stripos($attendee['delegated-from'], $task['_sender']) !== false || stripos($attendee['delegated-from'], $task['_sender_utf']) !== false) && (!in_array($attendee['email'], $existing_attendee_emails))) { $existing['attendees'][] = $attendee; } } // if delegatee has declined, set delegator's RSVP=True if ($task_attendee && $task_attendee['status'] == 'DECLINED' && $task_attendee['delegated-from']) { foreach ($existing['attendees'] as $i => $attendee) { if ($attendee['email'] == $task_attendee['delegated-from']) { $existing['attendees'][$i]['rsvp'] = true; break; } } } // found matching attendee entry in both existing and new events if ($existing_attendee >= 0 && $task_attendee) { $existing['attendees'][$existing_attendee] = $task_attendee; $success = $this->driver->edit_task($existing); } // update the entire attendees block elseif (($task['sequence'] >= $existing['sequence'] || $task['changed'] >= $existing['changed']) && $task_attendee) { $existing['attendees'][] = $task_attendee; $success = $this->driver->edit_task($existing); } else { $error_msg = $this->gettext('newerversionexists'); } } // delete the task when declined elseif ($status == 'declined' && $delete) { $deleted = $this->driver->delete_task($existing, true); $success = true; } // import the (newer) task elseif ($task['sequence'] >= $existing['sequence'] || $task['changed'] >= $existing['changed']) { $task['id'] = $existing['id']; $task['list'] = $existing['list']; // preserve my participant status for regular updates if (empty($status)) { $this->lib->merge_attendees($task, $existing); } // set status=CANCELLED on CANCEL messages if ($task['_method'] == 'CANCEL') { $task['status'] = 'CANCELLED'; } // update attachments list, allow attachments update only on REQUEST (#5342) if ($task['_method'] == 'REQUEST') { $task['deleted_attachments'] = true; } else { unset($task['attachments']); } // show me as free when declined (#1670) if ($status == 'declined' || $task['status'] == 'CANCELLED') { $task['free_busy'] = 'free'; } $success = $this->driver->edit_task($task); } elseif (!empty($status)) { $existing['attendees'] = $task['attendees']; if ($status == 'declined') { // show me as free when declined (#1670) $existing['free_busy'] = 'free'; } $success = $this->driver->edit_task($existing); } else { $error_msg = $this->gettext('newerversionexists'); } } elseif ($status != 'declined' || $this->rc->config->get('kolab_invitation_tasklists')) { $success = $this->driver->create_task($task); } elseif ($status == 'declined') { $error_msg = null; } } elseif ($status == 'declined' || $dontsave) { $error_msg = null; } else { $error_msg = $this->gettext('nowritetasklistfound'); } } if ($success || $dontsave) { if ($success) { $message = $task['_method'] == 'REPLY' ? 'attendeupdateesuccess' : ($deleted ? 'successremoval' : ($existing ? 'updatedsuccessfully' : 'importedsuccessfully')); $this->rc->output->command('display_message', $this->gettext(['name' => $message, 'vars' => ['list' => $list['name'] ?? '']]), 'confirmation'); } $metadata['rsvp'] = !empty($metadata['rsvp']) ? 1 : 0; $metadata['after_action'] = $this->rc->config->get('calendar_itip_after_action', 0); $this->rc->output->command('plugin.itip_message_processed', $metadata); $error_msg = null; } elseif ($error_msg) { $this->rc->output->command('display_message', $error_msg, 'error'); } // send iTip reply if ($task['_method'] == 'REQUEST' && $organizer && !$noreply && !$error_msg && (empty($emails) || !in_array(strtolower($organizer['email']), $emails)) ) { $task['comment'] = $comment; $itip = $this->load_itip(); $itip->set_sender_email($reply_sender); if ($itip->send_itip_message($this->to_libcal($task), 'REPLY', $organizer, 'itipsubject' . $status, 'itipmailbody' . $status)) { $this->rc->output->command('display_message', $this->gettext(['name' => 'sentresponseto', 'vars' => ['mailto' => $organizer['name'] ?: $organizer['email']]]), 'confirmation'); } else { $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error'); } } $this->rc->output->send(); } /**** Task invitation plugin hooks ****/ /** * Handler for task/itip-delegate requests */ public function mail_itip_delegate() { // forward request to mail_import_itip() with the right status $_POST['_status'] = $_REQUEST['_status'] = 'delegated'; $this->mail_import_itip(); } /** * Find a task in user tasklists */ protected function find_task($task, &$mode) { $this->load_driver(); // We search for writeable folders in personal namespace by default $mode = tasklist_driver::FILTER_WRITEABLE | tasklist_driver::FILTER_PERSONAL; $result = $this->driver->get_task($task, $mode); // ... now check shared folders if not found if (!$result) { $result = $this->driver->get_task($task, tasklist_driver::FILTER_WRITEABLE | tasklist_driver::FILTER_SHARED); if ($result) { $mode |= tasklist_driver::FILTER_SHARED; } } return $result; } /** * Handler for task/itip-status requests */ public function task_itip_status() { $data = rcube_utils::get_input_value('data', rcube_utils::INPUT_POST, true); // find local copy of the referenced task $existing = $this->find_task($data, $mode); $is_shared = $mode & tasklist_driver::FILTER_SHARED; $itip = $this->load_itip(); $response = $itip->get_itip_status($data, $existing); // get a list of writeable lists to save new tasks to if ((!$existing || $is_shared) && $response['action'] == 'rsvp' || $response['action'] == 'import') { $lists = $this->driver->get_lists($mode); $select = new html_select(['name' => 'tasklist', 'id' => 'itip-saveto', 'is_escaped' => true, 'class' => 'form-control']); $select->add('--', ''); foreach ($lists as $list) { if ($list['editable']) { $select->add($list['name'], $list['id']); } } $default_list = $this->get_default_tasklist($lists); $response['select'] = html::span('folder-select', $this->gettext('saveintasklist') . ' ' . $select->show($is_shared ? $existing['list'] : $default_list['id'])); } $this->rc->output->command('plugin.update_itip_object_status', $response); } /** * Handler for task/itip-remove requests */ public function task_itip_remove() { $success = false; $uid = rcube_utils::get_input_value('uid', rcube_utils::INPUT_POST); // search for event if only UID is given if ($task = $this->driver->get_task($uid)) { $success = $this->driver->delete_task($task, true); } if ($success) { $this->rc->output->show_message('tasklist.successremoval', 'confirmation'); } else { $this->rc->output->show_message('tasklist.errorsaving', 'error'); } } /******* Utility functions *******/ /** * Generate a unique identifier for an event */ public function generate_uid() { return strtoupper(md5(time() . uniqid(rand())) . '-' . substr(md5($this->rc->user->get_username()), 0, 16)); } /** * Map task properties for ical exprort using libcalendaring */ public function to_libcal($task) { $object = $task; $object['_type'] = 'task'; $object['categories'] = (array)$task['tags']; // convert to datetime objects if (!empty($task['date'])) { $object['due'] = rcube_utils::anytodatetime($task['date'] . ' ' . $task['time'], $this->timezone); if ($object['due'] && empty($task['time'])) { $object['due']->_dateonly = true; } unset($object['date']); } if (!empty($task['startdate'])) { $object['start'] = rcube_utils::anytodatetime($task['startdate'] . ' ' . $task['starttime'], $this->timezone); if ($object['start'] && empty($task['starttime'])) { $object['start']->_dateonly = true; } unset($object['startdate']); } $object['complete'] = $task['complete'] * 100; if ($task['complete'] == 1.0 && empty($task['complete'])) { $object['status'] = 'COMPLETED'; } if ($task['flagged']) { $object['priority'] = 1; } elseif (empty($task['priority'])) { $object['priority'] = 0; } return $object; } /** * Convert task properties from ical parser to the internal format */ public function from_ical($vtodo) { $task = $vtodo; $task['tags'] = array_filter((array)$vtodo['categories']); $task['flagged'] = $vtodo['priority'] == 1; $task['complete'] = floatval($vtodo['complete'] / 100); // convert from DateTime to internal date format if (is_a($vtodo['due'], 'DateTime')) { $due = $this->lib->adjust_timezone($vtodo['due']); $task['date'] = $due->format('Y-m-d'); if (empty($vtodo['due']->_dateonly)) { $task['time'] = $due->format('H:i'); } } // convert from DateTime to internal date format if (is_a($vtodo['start'], 'DateTime')) { $start = $this->lib->adjust_timezone($vtodo['start']); $task['startdate'] = $start->format('Y-m-d'); if (empty($vtodo['start']->_dateonly)) { $task['starttime'] = $start->format('H:i'); } } if (is_a($vtodo['dtstamp'], 'DateTime')) { $task['changed'] = $vtodo['dtstamp']; } unset($task['categories'], $task['due'], $task['start'], $task['dtstamp']); return $task; } /** * Handler for user_delete plugin hook */ public function user_delete($args) { $this->load_driver(); return $this->driver->user_delete($args); } /** * Magic getter for public access to protected members */ public function __get($name) { switch ($name) { case 'ical': return $this->get_ical(); case 'itip': return $this->load_itip(); case 'driver': $this->load_driver(); return $this->driver; } return null; } }