Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F223416
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
327 KB
Referenced Files
None
Subscribers
None
View Options
This file is larger than 256 KB, so syntax highlighting was skipped.
diff --git a/plugins/calendar/drivers/caldav/caldav_calendar.php b/plugins/calendar/drivers/caldav/caldav_calendar.php
new file mode 100644
index 00000000..b4e11b2e
--- /dev/null
+++ b/plugins/calendar/drivers/caldav/caldav_calendar.php
@@ -0,0 +1,904 @@
+<?php
+
+/**
+ * CalDAV calendar storage class
+ *
+ * @author Aleksander Machniak <machniak@apheleia-it.ch>
+ *
+ * Copyright (C) 2012-2022, Apheleia IT AG <contact@apheleia-it.ch>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+class caldav_calendar extends kolab_storage_dav_folder
+{
+ public $ready = false;
+ public $rights = 'lrs';
+ public $editable = false;
+ public $attachments = false; // TODO
+ public $alarms = false;
+ public $history = false;
+ public $subscriptions = false;
+ public $categories = [];
+ public $storage;
+
+ public $type = 'event';
+
+ protected $cal;
+ protected $events = [];
+ protected $search_fields = ['title', 'description', 'location', 'attendees', 'categories'];
+
+ /**
+ * Factory method to instantiate a caldav_calendar object
+ *
+ * @param string $id Calendar ID (encoded IMAP folder name)
+ * @param object $calendar Calendar plugin object
+ *
+ * @return caldav_calendar Self instance
+ */
+ public static function factory($id, $calendar)
+ {
+ return new caldav_calendar($id, $calendar);
+ }
+
+ /**
+ * Default constructor
+ */
+ public function __construct($folder_or_id, $calendar)
+ {
+ if ($folder_or_id instanceof kolab_storage_dav_folder) {
+ $this->storage = $folder_or_id;
+ }
+ else {
+ // $this->storage = kolab_storage_dav::get_folder($folder_or_id);
+ }
+
+ $this->cal = $calendar;
+ $this->id = $this->storage->id;
+ $this->attributes = $this->storage->attributes;
+ $this->ready = true;
+
+ // Set writeable and alarms flags according to folder permissions
+ if ($this->ready) {
+ if ($this->storage->get_namespace() == 'personal') {
+ $this->editable = true;
+ $this->rights = 'lrswikxteav';
+ $this->alarms = true;
+ }
+ else {
+ $rights = $this->storage->get_myrights();
+ if ($rights && !PEAR::isError($rights)) {
+ $this->rights = $rights;
+ if (strpos($rights, 't') !== false || strpos($rights, 'd') !== false) {
+ $this->editable = strpos($rights, 'i');;
+ }
+ }
+ }
+
+ // user-specific alarms settings win
+ $prefs = $this->cal->rc->config->get('kolab_calendars', []);
+ if (isset($prefs[$this->id]['showalarms'])) {
+ $this->alarms = $prefs[$this->id]['showalarms'];
+ }
+ }
+
+ $this->default = $this->storage->default;
+ $this->subtype = $this->storage->subtype;
+ }
+
+ /**
+ * Getter for the folder name
+ *
+ * @return string Name of the folder
+ */
+ public function get_realname()
+ {
+ return $this->get_name();
+ }
+
+ /**
+ * Return color to display this calendar
+ */
+ public function get_color($default = null)
+ {
+ if ($color = $this->storage->get_color()) {
+ return $color;
+ }
+
+ return $default ?: 'cc0000';
+ }
+
+ /**
+ * Compose an URL for CalDAV access to this calendar (if configured)
+ */
+ public function get_caldav_url()
+ {
+/*
+ if ($template = $this->cal->rc->config->get('calendar_caldav_url', null)) {
+ return strtr($template, [
+ '%h' => $_SERVER['HTTP_HOST'],
+ '%u' => urlencode($this->cal->rc->get_user_name()),
+ '%i' => urlencode($this->storage->get_uid()),
+ '%n' => urlencode($this->name),
+ ]);
+ }
+*/
+ return false;
+ }
+
+ /**
+ * Update properties of this calendar folder
+ *
+ * @see caldav_driver::edit_calendar()
+ */
+ public function update(&$prop)
+ {
+ // TODO
+ return null;
+ }
+
+ /**
+ * Getter for a single event object
+ */
+ public function get_event($id)
+ {
+ // remove our occurrence identifier if it's there
+ $master_id = preg_replace('/-\d{8}(T\d{6})?$/', '', $id);
+
+ // directly access storage object
+ if (empty($this->events[$id]) && $master_id == $id && ($record = $this->storage->get_object($id))) {
+ $this->events[$id] = $this->_to_driver_event($record, true);
+ }
+
+ // maybe a recurring instance is requested
+ if (empty($this->events[$id]) && $master_id != $id) {
+ $instance_id = substr($id, strlen($master_id) + 1);
+
+ if ($record = $this->storage->get_object($master_id)) {
+ $master = $this->_to_driver_event($record);
+ }
+
+ if ($master) {
+ // check for match in top-level exceptions (aka loose single occurrences)
+ if (!empty($master['_formatobj']) && ($instance = $master['_formatobj']->get_instance($instance_id))) {
+ $this->events[$id] = $this->_to_driver_event($instance, false, true, $master);
+ }
+ // check for match on the first instance already
+ else if (!empty($master['_instance']) && $master['_instance'] == $instance_id) {
+ $this->events[$id] = $master;
+ }
+ else if (!empty($master['recurrence'])) {
+ $start_date = $master['start'];
+ // For performance reasons we'll get only the specific instance
+ if (($date = substr($id, strlen($master_id) + 1, 8)) && strlen($date) == 8 && is_numeric($date)) {
+ $start_date = new DateTime($date . 'T000000', $master['start']->getTimezone());
+ }
+
+ $this->get_recurring_events($record, $start_date, null, $id, 1);
+ }
+ }
+ }
+
+ return $this->events[$id];
+ }
+
+ /**
+ * Get attachment body
+ * @see calendar_driver::get_attachment_body()
+ */
+ public function get_attachment_body($id, $event)
+ {
+ if (!$this->ready) {
+ return false;
+ }
+
+ $data = $this->storage->get_attachment($event['id'], $id);
+
+ if ($data == null) {
+ // try again with master UID
+ $uid = preg_replace('/-\d+(T\d{6})?$/', '', $event['id']);
+ if ($uid != $event['id']) {
+ $data = $this->storage->get_attachment($uid, $id);
+ }
+ }
+
+ return $data;
+ }
+
+ /**
+ * @param int Event's new start (unix timestamp)
+ * @param int Event's new end (unix timestamp)
+ * @param string Search query (optional)
+ * @param bool Include virtual events (optional)
+ * @param array Additional parameters to query storage
+ * @param array Additional query to filter events
+ *
+ * @return array A list of event records
+ */
+ public function list_events($start, $end, $search = null, $virtual = 1, $query = [], $filter_query = null)
+ {
+ // convert to DateTime for comparisons
+ // #5190: make the range a little bit wider
+ // to workaround possible timezone differences
+ try {
+ $start = new DateTime('@' . ($start - 12 * 3600));
+ }
+ catch (Exception $e) {
+ $start = new DateTime('@0');
+ }
+ try {
+ $end = new DateTime('@' . ($end + 12 * 3600));
+ }
+ catch (Exception $e) {
+ $end = new DateTime('today +10 years');
+ }
+
+ // get email addresses of the current user
+ $user_emails = $this->cal->get_user_emails();
+
+ // query Kolab storage
+ $query[] = ['dtstart', '<=', $end];
+ $query[] = ['dtend', '>=', $start];
+
+ if (is_array($filter_query)) {
+ $query = array_merge($query, $filter_query);
+ }
+
+ $words = [];
+ $partstat_exclude = [];
+ $events = [];
+
+ if (!empty($search)) {
+ $search = mb_strtolower($search);
+ $words = rcube_utils::tokenize_string($search, 1);
+ foreach (rcube_utils::normalize_string($search, true) as $word) {
+ $query[] = ['words', 'LIKE', $word];
+ }
+ }
+
+ // set partstat filter to skip pending and declined invitations
+ if (empty($filter_query)
+ && $this->cal->rc->config->get('kolab_invitation_calendars')
+ && $this->get_namespace() != 'other'
+ ) {
+ $partstat_exclude = ['NEEDS-ACTION', 'DECLINED'];
+ }
+
+ foreach ($this->storage->select($query) as $record) {
+ $event = $this->_to_driver_event($record, !$virtual, false);
+
+ // remember seen categories
+ if (!empty($event['categories'])) {
+ $cat = is_array($event['categories']) ? $event['categories'][0] : $event['categories'];
+ $this->categories[$cat]++;
+ }
+
+ // list events in requested time window
+ if ($event['start'] <= $end && $event['end'] >= $start) {
+ unset($event['_attendees']);
+ $add = true;
+
+ // skip the first instance of a recurring event if listed in exdate
+ if ($virtual && !empty($event['recurrence']['EXDATE'])) {
+ $event_date = $event['start']->format('Ymd');
+ $event_tz = $event['start']->getTimezone();
+
+ foreach ((array) $event['recurrence']['EXDATE'] as $exdate) {
+ $ex = clone $exdate;
+ $ex->setTimezone($event_tz);
+
+ if ($ex->format('Ymd') == $event_date) {
+ $add = false;
+ break;
+ }
+ }
+ }
+
+ // find and merge exception for the first instance
+ if ($virtual && !empty($event['recurrence']) && !empty($event['recurrence']['EXCEPTIONS'])) {
+ foreach ($event['recurrence']['EXCEPTIONS'] as $exception) {
+ if ($event['_instance'] == $exception['_instance']) {
+ unset($exception['calendar'], $exception['className'], $exception['_folder_id']);
+ // clone date objects from main event before adjusting them with exception data
+ if (is_object($event['start'])) {
+ $event['start'] = clone $record['start'];
+ }
+ if (is_object($event['end'])) {
+ $event['end'] = clone $record['end'];
+ }
+ kolab_driver::merge_exception_data($event, $exception);
+ }
+ }
+ }
+
+ if ($add) {
+ $events[] = $event;
+ }
+ }
+
+ // resolve recurring events
+ if (!empty($event['recurrence']) && $virtual == 1) {
+ $events = array_merge($events, $this->get_recurring_events($event, $start, $end));
+ }
+ // add top-level exceptions (aka loose single occurrences)
+ else if (!empty($record['exceptions'])) {
+ foreach ($record['exceptions'] as $ex) {
+ $component = $this->_to_driver_event($ex, false, false, $record);
+ if ($component['start'] <= $end && $component['end'] >= $start) {
+ $events[] = $component;
+ }
+ }
+ }
+ }
+
+ // post-filter all events by fulltext search and partstat values
+ $me = $this;
+ $events = array_filter($events, function($event) use ($words, $partstat_exclude, $user_emails, $me) {
+ // fulltext search
+ if (count($words)) {
+ $hits = 0;
+ foreach ($words as $word) {
+ $hits += $me->fulltext_match($event, $word, false);
+ }
+ if ($hits < count($words)) {
+ return false;
+ }
+ }
+
+ // partstat filter
+ if (count($partstat_exclude) && !empty($event['attendees'])) {
+ foreach ($event['attendees'] as $attendee) {
+ if (
+ in_array($attendee['email'], $user_emails)
+ && in_array($attendee['status'], $partstat_exclude)
+ ) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+ });
+
+ // Apply event-to-mail relations
+ $config = kolab_storage_config::get_instance();
+ $config->apply_links($events);
+
+ // avoid session race conditions that will loose temporary subscriptions
+ $this->cal->rc->session->nowrite = true;
+
+ return $events;
+ }
+
+ /**
+ * Get number of events in the given calendar
+ *
+ * @param int Date range start (unix timestamp)
+ * @param int Date range end (unix timestamp)
+ * @param array Additional query to filter events
+ *
+ * @return int Number of events
+ */
+ public function count_events($start, $end = null, $filter_query = null)
+ {
+ // convert to DateTime for comparisons
+ try {
+ $start = new DateTime('@'.$start);
+ }
+ catch (Exception $e) {
+ $start = new DateTime('@0');
+ }
+ if ($end) {
+ try {
+ $end = new DateTime('@'.$end);
+ }
+ catch (Exception $e) {
+ $end = null;
+ }
+ }
+
+ // query Kolab storage
+ $query[] = ['dtend', '>=', $start];
+
+ if ($end) {
+ $query[] = ['dtstart', '<=', $end];
+ }
+
+ // add query to exclude pending/declined invitations
+ if (empty($filter_query)) {
+ foreach ($this->cal->get_user_emails() as $email) {
+ $query[] = ['tags', '!=', 'x-partstat:' . $email . ':needs-action'];
+ $query[] = ['tags', '!=', 'x-partstat:' . $email . ':declined'];
+ }
+ }
+ else if (is_array($filter_query)) {
+ $query = array_merge($query, $filter_query);
+ }
+
+ return $this->storage->count($query);
+ }
+
+ /**
+ * Create a new event record
+ *
+ * @see calendar_driver::new_event()
+ *
+ * @return array|false The created record ID on success, False on error
+ */
+ public function insert_event($event)
+ {
+ if (!is_array($event)) {
+ return false;
+ }
+
+ // email links are stored separately
+ $links = !empty($event['links']) ? $event['links'] : [];
+ unset($event['links']);
+
+ // generate new event from RC input
+ $object = $this->_from_driver_event($event);
+ $saved = $this->storage->save($object, 'event');
+
+ if (!$saved) {
+ rcube::raise_error([
+ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__,
+ 'message' => "Error saving event object to DAV server"
+ ],
+ true, false
+ );
+ return false;
+ }
+
+ // save links in configuration.relation object
+ if ($this->save_links($event['uid'], $links)) {
+ $object['links'] = $links;
+ }
+
+ $this->events = [$event['uid'] => $this->_to_driver_event($object, true)];
+
+ return true;
+ }
+
+ /**
+ * Update a specific event record
+ *
+ * @return bool True on success, False on error
+ */
+ public function update_event($event, $exception_id = null)
+ {
+ $updated = false;
+ $old = $this->storage->get_object(!empty($event['uid']) ? $event['uid'] : $event['id']);
+
+ if (!$old || PEAR::isError($old)) {
+ return false;
+ }
+
+ // email links are stored separately
+ $links = !empty($event['links']) ? $event['links'] : [];
+ unset($event['links']);
+
+ $object = $this->_from_driver_event($event, $old);
+ $saved = $this->storage->save($object, 'event', $old['uid']);
+
+ if (!$saved) {
+ rcube::raise_error([
+ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__,
+ 'message' => "Error saving event object to CalDAV server"
+ ],
+ true, false
+ );
+ }
+ else {
+ // save links in configuration.relation object
+ if ($this->save_links($event['uid'], $links)) {
+ $object['links'] = $links;
+ }
+
+ $updated = true;
+ $this->events = [$event['uid'] => $this->_to_driver_event($object, true)];
+
+ // refresh local cache with recurring instances
+ if ($exception_id) {
+ $this->get_recurring_events($object, $event['start'], $event['end'], $exception_id);
+ }
+ }
+
+ return $updated;
+ }
+
+ /**
+ * Delete an event record
+ *
+ * @see calendar_driver::remove_event()
+ *
+ * @return bool True on success, False on error
+ */
+ public function delete_event($event, $force = true)
+ {
+ $uid = !empty($event['uid']) ? $event['uid'] : $event['id'];
+ $deleted = $this->storage->delete($uid, $force);
+
+ if (!$deleted) {
+ rcube::raise_error([
+ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__,
+ 'message' => "Error deleting event '{$uid}' from CalDAV server"
+ ],
+ true, false
+ );
+ }
+
+ return $deleted;
+ }
+
+ /**
+ * Restore deleted event record
+ *
+ * @see calendar_driver::undelete_event()
+ *
+ * @return bool True on success, False on error
+ */
+ public function restore_event($event)
+ {
+ // TODO
+ return false;
+ }
+
+ /**
+ * Find messages linked with an event
+ */
+ protected function get_links($uid)
+ {
+ return []; // TODO
+ $storage = kolab_storage_config::get_instance();
+ return $storage->get_object_links($uid);
+ }
+
+ /**
+ * Save message references (links) to an event
+ */
+ protected function save_links($uid, $links)
+ {
+ return false; // TODO
+ $storage = kolab_storage_config::get_instance();
+ return $storage->save_object_links($uid, (array) $links);
+ }
+
+ /**
+ * 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
+ * @param string $event_id ID of a specific recurring event instance
+ * @param int $limit Max. number of instances to return
+ *
+ * @return array List of recurring event instances
+ */
+ public function get_recurring_events($event, $start, $end = null, $event_id = null, $limit = null)
+ {
+ $object = $event['_formatobj'];
+
+ if (!is_object($object)) {
+ return [];
+ }
+
+ // determine a reasonable end date if none given
+ if (!$end) {
+ $end = clone $event['start'];
+ $end->add(new DateInterval('P100Y'));
+ }
+
+ // read recurrence exceptions first
+ $events = [];
+ $exdata = [];
+ $futuredata = [];
+ $recurrence_id_format = libcalendaring::recurrence_id_format($event);
+
+ if (!empty($event['recurrence'])) {
+ // copy the recurrence rule from the master event (to be used in the UI)
+ $recurrence_rule = $event['recurrence'];
+ unset($recurrence_rule['EXCEPTIONS'], $recurrence_rule['EXDATE']);
+
+ if (!empty($event['recurrence']['EXCEPTIONS'])) {
+ foreach ($event['recurrence']['EXCEPTIONS'] as $exception) {
+ if (empty($exception['_instance'])) {
+ $exception['_instance'] = libcalendaring::recurrence_instance_identifier($exception, !empty($event['allday']));
+ }
+
+ $rec_event = $this->_to_driver_event($exception, false, false, $event);
+ $rec_event['id'] = $event['uid'] . '-' . $exception['_instance'];
+ $rec_event['isexception'] = 1;
+
+ // found the specifically requested instance: register exception (single occurrence wins)
+ if (
+ $rec_event['id'] == $event_id
+ && (empty($this->events[$event_id]) || !empty($this->events[$event_id]['thisandfuture']))
+ ) {
+ $rec_event['recurrence'] = $recurrence_rule;
+ $rec_event['recurrence_id'] = $event['uid'];
+ $this->events[$rec_event['id']] = $rec_event;
+ }
+
+ // remember this exception's date
+ $exdate = substr($exception['_instance'], 0, 8);
+ if (empty($exdata[$exdate]) || !empty($exdata[$exdate]['thisandfuture'])) {
+ $exdata[$exdate] = $rec_event;
+ }
+ if (!empty($rec_event['thisandfuture'])) {
+ $futuredata[$exdate] = $rec_event;
+ }
+ }
+ }
+ }
+
+ // found the specifically requested instance, exiting...
+ if ($event_id && !empty($this->events[$event_id])) {
+ return [$this->events[$event_id]];
+ }
+
+ // Check first occurrence, it might have been moved
+ if ($first = $exdata[$event['start']->format('Ymd')]) {
+ // return it only if not already in the result, but in the requested period
+ if (!($event['start'] <= $end && $event['end'] >= $start)
+ && ($first['start'] <= $end && $first['end'] >= $start)
+ ) {
+ $events[] = $first;
+ }
+ }
+
+ if ($limit && count($events) >= $limit) {
+ return $events;
+ }
+
+ // use libkolab to compute recurring events
+ $recurrence = new kolab_date_recurrence($object);
+
+ $i = 0;
+ while ($next_event = $recurrence->next_instance()) {
+ $datestr = $next_event['start']->format('Ymd');
+ $instance_id = $next_event['start']->format($recurrence_id_format);
+
+ // use this event data for future recurring instances
+ if (!empty($futuredata[$datestr])) {
+ $overlay_data = $futuredata[$datestr];
+ }
+
+ $rec_id = $event['uid'] . '-' . $instance_id;
+ $exception = !empty($exdata[$datestr]) ? $exdata[$datestr] : $overlay_data;
+ $event_start = $next_event['start'];
+ $event_end = $next_event['end'];
+
+ // copy some event from exception to get proper start/end dates
+ if ($exception) {
+ $event_copy = $next_event;
+ caldav_driver::merge_exception_dates($event_copy, $exception);
+ $event_start = $event_copy['start'];
+ $event_end = $event_copy['end'];
+ }
+
+ // add to output if in range
+ if (($event_start <= $end && $event_end >= $start) || ($event_id && $rec_id == $event_id)) {
+ $rec_event = $this->_to_driver_event($next_event, false, false, $event);
+ $rec_event['_instance'] = $instance_id;
+ $rec_event['_count'] = $i + 1;
+
+ if ($exception) {
+ // copy data from exception
+ colab_driver::merge_exception_data($rec_event, $exception);
+ }
+
+ $rec_event['id'] = $rec_id;
+ $rec_event['recurrence_id'] = $event['uid'];
+ $rec_event['recurrence'] = $recurrence_rule;
+ unset($rec_event['_attendees']);
+ $events[] = $rec_event;
+
+ if ($rec_id == $event_id) {
+ $this->events[$rec_id] = $rec_event;
+ break;
+ }
+
+ if ($limit && count($events) >= $limit) {
+ return $events;
+ }
+ }
+ else if ($next_event['start'] > $end) {
+ // stop loop if out of range
+ break;
+ }
+
+ // avoid endless recursion loops
+ if (++$i > 100000) {
+ break;
+ }
+ }
+
+ return $events;
+ }
+
+ /**
+ * Convert from storage format to internal representation
+ */
+ private function _to_driver_event($record, $noinst = false, $links = true, $master_event = null)
+ {
+ $record['calendar'] = $this->id;
+
+ // remove (possibly outdated) cached parameters
+ unset($record['_folder_id'], $record['className']);
+
+ if ($links && !array_key_exists('links', $record)) {
+ $record['links'] = $this->get_links($record['uid']);
+ }
+
+ $ns = $this->get_namespace();
+
+ if ($ns == 'other') {
+ $record['className'] = 'fc-event-ns-other';
+ }
+
+ if ($ns == 'other' || !$this->cal->rc->config->get('kolab_invitation_calendars')) {
+ $record = caldav_driver::add_partstat_class($record, ['NEEDS-ACTION', 'DECLINED'], $this->get_owner());
+
+ // Modify invitation status class name, when invitation calendars are disabled
+ // we'll use opacity only for declined/needs-action events
+ $record['className'] = str_replace('-invitation', '', $record['className']);
+ }
+
+ // add instance identifier to first occurrence (master event)
+ $recurrence_id_format = libcalendaring::recurrence_id_format($master_event ? $master_event : $record);
+ if (!$noinst && !empty($record['recurrence']) && empty($record['recurrence_id']) && empty($record['_instance'])) {
+ $record['_instance'] = $record['start']->format($recurrence_id_format);
+ }
+ else if (isset($record['recurrence_date']) && is_a($record['recurrence_date'], 'DateTime')) {
+ $record['_instance'] = $record['recurrence_date']->format($recurrence_id_format);
+ }
+
+ // clean up exception data
+ if (!empty($record['recurrence']) && !empty($record['recurrence']['EXCEPTIONS'])) {
+ array_walk($record['recurrence']['EXCEPTIONS'], function(&$exception) {
+ unset($exception['_mailbox'], $exception['_msguid'], $exception['_formatobj'], $exception['_attachments']);
+ });
+ }
+
+ // Load the given event data into a libkolabxml container
+ // it's needed for recurrence resolving, which uses libcalendaring
+ // TODO: Drop dependency on libkolabxml?
+ $event_xml = new kolab_format_event();
+ $event_xml->set($record);
+ $event['_formatobj'] = $event_xml;
+
+ return $record;
+ }
+
+ /**
+ * Convert the given event record into a data structure that can be passed to the storage backend for saving
+ * (opposite of self::_to_driver_event())
+ */
+ private function _from_driver_event($event, $old = [])
+ {
+ // set current user as ORGANIZER
+ if ($identity = $this->cal->rc->user->list_emails(true)) {
+ $event['attendees'] = !empty($event['attendees']) ? $event['attendees'] : [];
+ $found = false;
+
+ // there can be only resources on attendees list (T1484)
+ // let's check the existence of an organizer
+ foreach ($event['attendees'] as $attendee) {
+ if (!empty($attendee['role']) && $attendee['role'] == 'ORGANIZER') {
+ $found = true;
+ break;
+ }
+ }
+
+ if (!$found) {
+ $event['attendees'][] = ['role' => 'ORGANIZER', 'name' => $identity['name'], 'email' => $identity['email']];
+ }
+
+ $event['_owner'] = $identity['email'];
+ }
+
+ // remove EXDATE values if RDATE is given
+ if (!empty($event['recurrence']['RDATE'])) {
+ $event['recurrence']['EXDATE'] = [];
+ }
+
+ // remove recurrence information (e.g. EXDATES and EXCEPTIONS) entirely
+ if (!empty($event['recurrence']) && empty($event['recurrence']['FREQ']) && empty($event['recurrence']['RDATE'])) {
+ $event['recurrence'] = [];
+ }
+
+ // keep 'comment' from initial itip invitation
+ if (!empty($old['comment'])) {
+ $event['comment'] = $old['comment'];
+ }
+
+ // remove some internal properties which should not be cached
+ $cleanup_fn = function(&$event) {
+ unset($event['_savemode'], $event['_fromcalendar'], $event['_identity'], $event['_folder_id'],
+ $event['calendar'], $event['className'], $event['recurrence_id'],
+ $event['attachments'], $event['deleted_attachments']);
+ };
+
+ $cleanup_fn($event);
+
+ // clean up exception data
+ if (!empty($event['exceptions'])) {
+ array_walk($event['exceptions'], function(&$exception) use ($cleanup_fn) {
+ unset($exception['_mailbox'], $exception['_msguid'], $exception['_formatobj']);
+ $cleanup_fn($exception);
+ });
+ }
+
+ // copy meta data (starting with _) from old object
+ foreach ((array) $old as $key => $val) {
+ if (!isset($event[$key]) && $key[0] == '_') {
+ $event[$key] = $val;
+ }
+ }
+
+ return $event;
+ }
+
+ /**
+ * Match the given word in the event contents
+ */
+ public function fulltext_match($event, $word, $recursive = true)
+ {
+ $hits = 0;
+ foreach ($this->search_fields as $col) {
+ if (empty($event[$col])) {
+ continue;
+ }
+
+ $sval = is_array($event[$col]) ? self::_complex2string($event[$col]) : $event[$col];
+ if (empty($sval)) {
+ continue;
+ }
+
+ // do a simple substring matching (to be improved)
+ $val = mb_strtolower($sval);
+ if (strpos($val, $word) !== false) {
+ $hits++;
+ break;
+ }
+ }
+
+ return $hits;
+ }
+
+ /**
+ * Convert a complex event attribute to a string value
+ */
+ private static function _complex2string($prop)
+ {
+ static $ignorekeys = ['role', 'status', 'rsvp'];
+
+ $out = '';
+ if (is_array($prop)) {
+ foreach ($prop as $key => $val) {
+ if (is_numeric($key)) {
+ $out .= self::_complex2string($val);
+ }
+ else if (!in_array($key, $ignorekeys)) {
+ $out .= $val . ' ';
+ }
+ }
+ }
+ else if (is_string($prop) || is_numeric($prop)) {
+ $out .= $prop . ' ';
+ }
+
+ return rtrim($out);
+ }
+}
diff --git a/plugins/calendar/drivers/caldav/caldav_driver.php b/plugins/calendar/drivers/caldav/caldav_driver.php
new file mode 100644
index 00000000..9e38cc87
--- /dev/null
+++ b/plugins/calendar/drivers/caldav/caldav_driver.php
@@ -0,0 +1,527 @@
+<?php
+
+/**
+ * CalDAV driver for the Calendar plugin.
+ *
+ * @author Aleksander Machniak <machniak@apheleia-it.ch>
+ *
+ * Copyright (C) 2012-2022, Apheleia IT AG <contact@apheleia-it.ch>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+require_once(__DIR__ . '/../kolab/kolab_driver.php');
+
+class caldav_driver extends kolab_driver
+{
+ // features this backend supports
+ public $alarms = true;
+ public $attendees = true;
+ public $freebusy = true;
+ public $attachments = false; // TODO
+ public $undelete = false; // TODO
+ public $alarm_types = ['DISPLAY', 'AUDIO'];
+ public $categoriesimmutable = true;
+
+ /**
+ * Default constructor
+ */
+ public function __construct($cal)
+ {
+ $cal->require_plugin('libkolab');
+
+ // load helper classes *after* libkolab has been loaded (#3248)
+ require_once(__DIR__ . '/caldav_calendar.php');
+ // require_once(__DIR__ . '/kolab_user_calendar.php');
+ // require_once(__DIR__ . '/caldav_invitation_calendar.php');
+
+ $this->cal = $cal;
+ $this->rc = $cal->rc;
+
+ // Initialize the CalDAV storage
+ $url = $this->rc->config->get('calendar_caldav_server', 'http://localhost');
+ $this->storage = new kolab_storage_dav($url);
+
+ $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);
+
+ // TODO: get configuration for the Bonnie API
+ // $this->bonnie_api = libkolab::get_bonnie_api();
+ }
+
+ /**
+ * Read available calendars from server
+ */
+ protected function _read_calendars()
+ {
+ // already read sources
+ if (isset($this->calendars)) {
+ return $this->calendars;
+ }
+
+ // get all folders that support VEVENT, sorted by namespace/name
+ $folders = $this->storage->get_folders('event');
+ // + $this->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 caldav_calendar
+ */
+ protected function _to_calendar($folder)
+ {
+ if ($folder instanceof caldav_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 caldav_calendar($folder, $this->cal);
+ }
+
+ return $calendar;
+ }
+
+ /**
+ * Get a list of available calendars from this source.
+ *
+ * @param int $filter Bitmask defining filter criterias
+ * @param object $tree Reference to hierarchical folder tree object
+ *
+ * @return array List of calendars
+ */
+ public function list_calendars($filter = 0, &$tree = null)
+ {
+ $this->_read_calendars();
+
+ $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) {
+/*
+ $path = explode('/', $cal->name);
+
+ // find parent
+ do {
+ array_pop($path);
+ $parent_id = $this->storage->folder_id(implode('/', $path));
+ }
+ while (count($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->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_dav::folder_hierarchy() above
+ // make sure we deal with caldav_calendar instances
+ $cal = $this->_to_calendar($cal);
+ $this->calendars[$cal->id] = $cal;
+
+ $is_user = ($cal instanceof caldav_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 ($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 caldav_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 the caldav_calendar instance for the given calendar ID
+ *
+ * @param string Calendar identifier (encoded imap folder name)
+ *
+ * @return ?caldav_calendar Object nor 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 caldav_invitation_calendar($id, $this->cal);
+ }
+
+ // for unsubscribed calendar folders
+ if ($id !== self::BIRTHDAY_CALENDAR_ID) {
+ $calendar = caldav_calendar::factory($id, $this->cal);
+ if ($calendar->ready) {
+ $this->calendars[$calendar->id] = $calendar;
+ }
+ }
+ }
+
+ return !empty($this->calendars[$id]) ? $this->calendars[$id] : null;
+ }
+
+ /**
+ * Search for shared or otherwise not listed calendars the user has access
+ *
+ * @param string Search string
+ * @param string Section/source to search
+ *
+ * @return array List of calendars
+ */
+ public function search_calendars($query, $source)
+ {
+ $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
+ else if ($source == 'users') {
+ // we have slightly more space, so display twice the number
+ $limit = $this->rc->config->get('autocomplete_max', 15) * 2;
+
+ foreach ($this->storage->search_users($query, 0, [], $limit, $count) as $user) {
+ $calendar = new caldav_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 caldav_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();
+ }
+
+ /**
+ * Get events from source.
+ *
+ * @param int Event's new start (unix timestamp)
+ * @param int Event's new end (unix timestamp)
+ * @param string Search query (optional)
+ * @param mixed List of calendar IDs to load events from (either as array or comma-separated string)
+ * @param bool Include virtual events (optional)
+ * @param int 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 = 1, $modifiedsince = null)
+ {
+ if ($calendars && is_string($calendars)) {
+ $calendars = explode(',', $calendars);
+ }
+ else if (!$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, 'caldav_driver::to_rcube_event');
+
+ return $events;
+ }
+
+ /**
+ * Create instances of a recurring event
+ *
+ * @param array Hash array with event properties
+ * @param DateTime Start date of the recurrence window
+ * @param DateTime 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
+ $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
+ $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;
+ }
+
+ /**
+ * Callback function to produce driver-specific calendar create/edit form
+ *
+ * @param string Request action 'form-edit|form-new'
+ * @param array Calendar properties (e.g. id, color)
+ * @param array 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];
+
+ $form = [];
+ $protected = false; // TODO
+
+ // Disable folder name input
+ if ($protected) {
+ $input_name = new html_hiddenfield(['name' => 'name', 'id' => 'calendar-name']);
+ $formfields['name']['value'] = $this->storage->object_name($folder)
+ . $input_name->show($folder);
+ }
+
+ // calendar name (default field)
+ $form['props']['fields']['location'] = $formfields['name'];
+
+ if ($protected) {
+ // prevent user from moving folder
+ $hidden_fields[] = ['name' => 'parent', 'value' => '']; // TODO
+ }
+ 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) ? '' : ''), // TODO
+ ];
+ }
+
+ // 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);
+ }
+}
diff --git a/plugins/calendar/drivers/caldav/caldav_invitation_calendar.php b/plugins/calendar/drivers/caldav/caldav_invitation_calendar.php
new file mode 100644
index 00000000..3d34c992
--- /dev/null
+++ b/plugins/calendar/drivers/caldav/caldav_invitation_calendar.php
@@ -0,0 +1,47 @@
+<?php
+
+/**
+ * CalDAV calendar storage class simulating a virtual calendar listing pedning/declined invitations
+ *
+ * @author Aleksander Machniak <machniak@apheleia-it.ch>
+ *
+ * Copyright (C) 2014-2022, Apheleia IT AG <contact@apheleia-it.ch>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+require_once(__DIR__ . '/../kolab/kolab_driver.php');
+require_once(__DIR__ . '/../kolab/kolab_invitation_calendar.php');
+
+class caldav_invitation_calendar extends kolab_invitation_calendar
+{
+ public $id = '__caldav_invitation__';
+
+ /**
+ * Default constructor
+ */
+ public function __construct($id, $calendar)
+ {
+ $this->cal = $calendar;
+ $this->id = $id;
+ }
+
+ /**
+ * Compose an URL for CalDAV access to this calendar (if configured)
+ */
+ public function get_caldav_url()
+ {
+ return false; // TODO
+ }
+}
diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php
index 005cac77..42ef7cf6 100644
--- a/plugins/calendar/drivers/kolab/kolab_driver.php
+++ b/plugins/calendar/drivers/kolab/kolab_driver.php
@@ -1,2644 +1,2646 @@
<?php
/**
* Kolab driver for the Calendar plugin
*
* @version @package_version@
* @author Thomas Bruederli <bruederli@kolabsys.com>
* @author Aleksander Machniak <machniak@kolabsys.com>
*
* Copyright (C) 2012-2015, Kolab Systems AG <contact@kolabsys.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
class kolab_driver extends calendar_driver
{
const INVITATIONS_CALENDAR_PENDING = '--invitation--pending';
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;
- private $rc;
- private $cal;
- private $calendars;
- private $has_writeable = false;
- private $freebusy_trigger = false;
- private $bonnie_api = false;
+ protected $rc;
+ protected $cal;
+ protected $calendars;
+ protected $storage;
+ protected $has_writeable = false;
+ protected $freebusy_trigger = false;
+ protected $bonnie_api = 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->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 (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
*/
- private function _read_calendars()
+ 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 = kolab_storage::sort_folders(
- kolab_storage::get_folders('event') + kolab_storage::get_user_folders('event', true)
+ $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
*/
- private function _to_calendar($folder)
+ 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 object $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 = kolab_storage::folder_hierarchy($folders, $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 = kolab_storage::folder_id(join($delim, $imap_path));
+ $parent_id = $this->storage->folder_id(join($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 = kolab_storage::folder_id($cal->get_parent());
+ $parent_id = $this->storage->folder_id($cal->get_parent());
}
$parents[] = $cal->id;
if ($cal->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 ($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 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 Calendar identifier (encoded imap folder name)
*
* @return kolab_calendar Object nor 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;
}
/**
* Create a new calendar assigned to the current user
*
* @param array 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 = kolab_storage::folder_update($prop);
+ $folder = $this->storage->folder_update($prop);
if ($folder === false) {
- $this->last_error = $this->cal->gettext(kolab_storage::$last_error);
+ $this->last_error = $this->cal->gettext($this->storage->last_error);
return false;
}
// create ID
- $id = kolab_storage::folder_id($folder);
+ $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] : '';
}
else 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 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) kolab_storage::list_folders($cal->storage->name, '*', 'event') as $subfolder) {
+ foreach ((array) $this->storage->list_folders($cal->storage->name, '*', 'event') as $subfolder) {
if (isset($prop['permanent'])) {
if ($prop['permanent']) {
- kolab_storage::folder_subscribe($subfolder);
+ $this->storage->folder_subscribe($subfolder);
}
else {
- kolab_storage::folder_unsubscribe($subfolder);
+ $this->storage->folder_unsubscribe($subfolder);
}
}
if (isset($prop['active'])) {
if ($prop['active']) {
- kolab_storage::folder_activate($subfolder);
+ $this->storage->folder_activate($subfolder);
}
else {
- kolab_storage::folder_deactivate($subfolder);
+ $this->storage->folder_deactivate($subfolder);
}
}
}
}
return $ret;
}
else {
// 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;
}
return false;
}
/**
* 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 (kolab_storage::folder_delete($folder)) {
+ 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 = kolab_storage::$last_error;
+ $this->last_error = $this->storage->last_error;
}
}
return false;
}
/**
* Search for shared or otherwise not listed calendars the user has access
*
* @param string Search string
* @param string Section/source to search
*
* @return array List of calendars
*/
public function search_calendars($query, $source)
{
- if (!kolab_storage::setup()) {
+ 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) kolab_storage::search_folders('event', $query, ['other']) as $folder) {
+ 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
else if ($source == 'users') {
// we have slightly more space, so display twice the number
$limit = $this->rc->config->get('autocomplete_max', 15) * 2;
- foreach (kolab_storage::search_users($query, 0, [], $limit, $count) as $user) {
+ 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 (kolab_storage::list_user_folders($user, 'event', false) as $foldername) {
+ 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'];
// 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 false;
}
/**
* 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 Hash array with event properties
* @param string New participant status
* @param array 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;
}
else if (strtoupper($status) == 'NEEDS-ACTION') {
$event['calendar'] = self::INVITATIONS_CALENDAR_PENDING;
}
else if (!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 boolean 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 boolean 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 Hash array with event properties:
* id: Event identifier
* @param bool 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 = isset($event['_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 = $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'];
}
else if (!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 ($exception['_instance'] == $event['_instance']) {
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'] = $master;
// 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'] = $master;
// 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
else if (!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;
}
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);
}
else if ($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 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 = $_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
*/
- private function update_event($event)
+ 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 ($event['_savemode'] != 'new') {
if (!$fromcalendar->storage->move($old['uid'], $storage->storage)) {
return false;
}
$fromcalendar = $storage;
}
}
else {
$fromcalendar = $storage;
}
$success = false;
$savemode = 'all';
$attachments = [];
$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
else if (empty($old['recurrence']) && empty($old['recurrence_id']) && !empty($old['isexception'])) {
$savemode = 'current';
}
// Stick to the master timezone for all occurrences (Bifrost#T104637)
$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;
}
else if (!empty($old['recurrence']['EXCEPTIONS'])) {
$event['recurrence']['EXCEPTIONS'] = $old['recurrence']['EXCEPTIONS'];
}
else if (!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'];
}
);
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'];
}
);
}
// set link to top-level exceptions
$event['exceptions'] = &$event['recurrence']['EXCEPTIONS'];
}
// 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'], $master['sequence']) + 1;
}
else if (!isset($event['sequence'])) {
$event['sequence'] = !empty($old['sequence']) ? $old['sequence'] : $master['sequence'];
}
// 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']);
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
else if ($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'];
}
else if (!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']) && !$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']) && is_a($exception['recurrence_date'], 'DateTime')) {
$recurrence_id = $exception['recurrence_date'];
}
else {
$recurrence_id = rcube_utils::anytodatetime($exception['_instance'], $old['start']->getTimezone());
}
if ($recurrence_id instanceof DateTime) {
$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
*/
public 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;
}
else if ($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 ($exception['_instance'] == $old['_instance']) {
$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'] = $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'] >= $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'];
if (empty($event['recurrence_date'])) {
$event['recurrence_date'] = !empty($old['recurrence_date']) ? $old['recurrence_date'] : $old['start'];
}
}
else if (empty($event['recurrence_date'])) {
$event['recurrence_date'] = $event['start'];
}
if (empty($event['_instance']) && is_a($event['recurrence_date'], 'DateTime')) {
$event['_instance'] = libcalendaring::recurrence_instance_identifier($event, !empty($master['allday']));
}
if (!is_array($master['exceptions']) && isset($master['recurrence']['EXCEPTIONS'])) {
$master['exceptions'] = &$master['recurrence']['EXCEPTIONS'];
}
$existing = false;
foreach ((array) $master['exceptions'] as $i => $exception) {
if ($exception['_instance'] == $event['_instance']) {
$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 The event object to be altered
* @param array The overlay event object to be merged over $event
* @param array 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
}
else if ($prop == 'thisandfuture' && $overlay['_instance'] == $event['_instance']) {
$event[$prop] = $value;
}
else if ($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 The event object to be altered
* @param array 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 DateTime && $overlay['recurrence_date'] instanceof DateTime) {
$date_offset = $overlay['recurrence_date']->diff($overlay['start']);
}
foreach (['start', 'end'] as $prop) {
$value = $overlay[$prop];
if (isset($event[$prop]) && $event[$prop] instanceof DateTime) {
// 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
else if (!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 Event's new start (unix timestamp)
* @param int Event's new end (unix timestamp)
* @param string Search query (optional)
* @param mixed List of calendar IDs to load events from (either as array or comma-separated string)
* @param bool Include virtual events (optional)
* @param int 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 = 1, $modifiedsince = null)
{
if ($calendars && is_string($calendars)) {
$calendars = explode(',', $calendars);
}
else if (!$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 List of calendar IDs to count events (either as array or comma-separated string)
* @param int Date range start (unix timestamp)
* @param int 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);
}
else if (!$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'],
'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 (" . join(',', $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) {
// skip dismissed alarms
if ($dbdata[$id]['dismissed']) {
continue;
}
// snooze function may have shifted alarm time
$notifyat = $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 false;
}
$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 = isset($event['_attachments']) ? $event['_attachments'] : $event['attachments'];
foreach ((array) $attachments as $att) {
if ($att['id'] == $id) {
return $att;
}
}
}
}
/**
* 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
list($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 Hash array with event properties
* @param DateTime Start date of the recurrence window
* @param DateTime 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);
}
/**
*
*/
- private function get_recurrence_count($event, $dtstart)
+ 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;
}
// 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(kolab_storage::get_freebusy_url($email), 'GET', $request_config);
+ $request = libkolab::http_request($this->storage->get_freebusy_url($email), '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) {
PEAR::raiseError("Error fetching free/busy information: " . $e->getMessage());
}
// 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) {
list($from, $to, $type) = $tuple;
$result[] = [
$from->format('U'),
$to->format('U'),
isset($fbtypemap[$type]) ? $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'];
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 (is_a($record['start'], 'DateTime') && $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'])) {
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'] = join(', ', $attendee['delegated-from']);
}
if (isset($attendee['delegated-to']) && is_array($attendee['delegated-to'])) {
$record['attendees'][$i]['delegated-to'] = join(', ', $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
else if (!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)) {
$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 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;
}
list($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 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;
}
list($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($old['old']) ? 'free' : 'busy';
$change['new'] = !empty($old['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;
}
else if ($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 Hash array with event properties
* @param mixed $rev Revision number
*
* @return array 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'];
list($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'])) {
$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;
}
list($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
*/
- private function _resolve_event_identity($event)
+ 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 array($uid, $mailbox, $msguid);
}
/**
* Callback function to produce driver-specific calendar create/edit form
*
* @param string Request action 'form-edit|form-new'
* @param array Calendar properties (e.g. id, color)
* @param array 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']);
- $formfields['name']['value'] = kolab_storage::object_name($folder)
+ $formfields['name']['value'] = $this->storage->object_name($folder)
. $input_name->show($folder);
}
// 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 = kolab_storage::folder_selector('event', ['name' => 'parent', 'id' => 'calendar-parent'], $folder);
+ $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);
}
}
}
diff --git a/plugins/libkolab/SQL/mysql.initial.sql b/plugins/libkolab/SQL/mysql.initial.sql
index 33b8a57d..f56486f4 100644
--- a/plugins/libkolab/SQL/mysql.initial.sql
+++ b/plugins/libkolab/SQL/mysql.initial.sql
@@ -1,182 +1,199 @@
/**
* libkolab database schema
*
* @author Thomas Bruederli
* @licence GNU AGPL
*/
SET FOREIGN_KEY_CHECKS=0;
DROP TABLE IF EXISTS `kolab_folders`;
CREATE TABLE `kolab_folders` (
`folder_id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`resource` VARCHAR(255) BINARY NOT NULL,
`type` VARCHAR(32) NOT NULL,
`synclock` INT(10) NOT NULL DEFAULT '0',
`ctag` VARCHAR(40) DEFAULT NULL,
`changed` DATETIME DEFAULT NULL,
`objectcount` BIGINT DEFAULT NULL,
PRIMARY KEY(`folder_id`),
INDEX `resource_type` (`resource`, `type`)
) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
DROP TABLE IF EXISTS `kolab_cache`;
DROP TABLE IF EXISTS `kolab_cache_contact`;
CREATE TABLE `kolab_cache_contact` (
`folder_id` BIGINT UNSIGNED NOT NULL,
`msguid` BIGINT UNSIGNED NOT NULL,
`uid` VARCHAR(512) NOT NULL,
`created` DATETIME DEFAULT NULL,
`changed` DATETIME DEFAULT NULL,
`data` LONGTEXT NOT NULL,
`tags` TEXT NOT NULL,
`words` TEXT NOT NULL,
`type` VARCHAR(32) CHARACTER SET ascii NOT NULL,
`name` VARCHAR(255) NOT NULL,
`firstname` VARCHAR(255) NOT NULL,
`surname` VARCHAR(255) NOT NULL,
`email` VARCHAR(255) NOT NULL,
CONSTRAINT `fk_kolab_cache_contact_folder` FOREIGN KEY (`folder_id`)
REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE,
PRIMARY KEY(`folder_id`,`msguid`),
INDEX `contact_type` (`folder_id`,`type`),
INDEX `contact_uid2msguid` (`folder_id`,`uid`,`msguid`)
) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
DROP TABLE IF EXISTS `kolab_cache_event`;
CREATE TABLE `kolab_cache_event` (
`folder_id` BIGINT UNSIGNED NOT NULL,
`msguid` BIGINT UNSIGNED NOT NULL,
`uid` VARCHAR(512) NOT NULL,
`created` DATETIME DEFAULT NULL,
`changed` DATETIME DEFAULT NULL,
`data` LONGTEXT NOT NULL,
`tags` TEXT NOT NULL,
`words` TEXT NOT NULL,
`dtstart` DATETIME,
`dtend` DATETIME,
CONSTRAINT `fk_kolab_cache_event_folder` FOREIGN KEY (`folder_id`)
REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE,
PRIMARY KEY(`folder_id`,`msguid`),
INDEX `event_uid2msguid` (`folder_id`,`uid`,`msguid`)
) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
DROP TABLE IF EXISTS `kolab_cache_task`;
CREATE TABLE `kolab_cache_task` (
`folder_id` BIGINT UNSIGNED NOT NULL,
`msguid` BIGINT UNSIGNED NOT NULL,
`uid` VARCHAR(512) NOT NULL,
`created` DATETIME DEFAULT NULL,
`changed` DATETIME DEFAULT NULL,
`data` LONGTEXT NOT NULL,
`tags` TEXT NOT NULL,
`words` TEXT NOT NULL,
`dtstart` DATETIME,
`dtend` DATETIME,
CONSTRAINT `fk_kolab_cache_task_folder` FOREIGN KEY (`folder_id`)
REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE,
PRIMARY KEY(`folder_id`,`msguid`),
INDEX `task_uid2msguid` (`folder_id`,`uid`,`msguid`)
) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
DROP TABLE IF EXISTS `kolab_cache_journal`;
CREATE TABLE `kolab_cache_journal` (
`folder_id` BIGINT UNSIGNED NOT NULL,
`msguid` BIGINT UNSIGNED NOT NULL,
`uid` VARCHAR(512) NOT NULL,
`created` DATETIME DEFAULT NULL,
`changed` DATETIME DEFAULT NULL,
`data` LONGTEXT NOT NULL,
`tags` TEXT NOT NULL,
`words` TEXT NOT NULL,
`dtstart` DATETIME,
`dtend` DATETIME,
CONSTRAINT `fk_kolab_cache_journal_folder` FOREIGN KEY (`folder_id`)
REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE,
PRIMARY KEY(`folder_id`,`msguid`),
INDEX `journal_uid2msguid` (`folder_id`,`uid`,`msguid`)
) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
DROP TABLE IF EXISTS `kolab_cache_note`;
CREATE TABLE `kolab_cache_note` (
`folder_id` BIGINT UNSIGNED NOT NULL,
`msguid` BIGINT UNSIGNED NOT NULL,
`uid` VARCHAR(512) NOT NULL,
`created` DATETIME DEFAULT NULL,
`changed` DATETIME DEFAULT NULL,
`data` LONGTEXT NOT NULL,
`tags` TEXT NOT NULL,
`words` TEXT NOT NULL,
CONSTRAINT `fk_kolab_cache_note_folder` FOREIGN KEY (`folder_id`)
REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE,
PRIMARY KEY(`folder_id`,`msguid`),
INDEX `note_uid2msguid` (`folder_id`,`uid`,`msguid`)
) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
DROP TABLE IF EXISTS `kolab_cache_file`;
CREATE TABLE `kolab_cache_file` (
`folder_id` BIGINT UNSIGNED NOT NULL,
`msguid` BIGINT UNSIGNED NOT NULL,
`uid` VARCHAR(512) NOT NULL,
`created` DATETIME DEFAULT NULL,
`changed` DATETIME DEFAULT NULL,
`data` LONGTEXT NOT NULL,
`tags` TEXT NOT NULL,
`words` TEXT NOT NULL,
`filename` varchar(255) DEFAULT NULL,
CONSTRAINT `fk_kolab_cache_file_folder` FOREIGN KEY (`folder_id`)
REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE,
PRIMARY KEY(`folder_id`,`msguid`),
INDEX `folder_filename` (`folder_id`, `filename`),
INDEX `file_uid2msguid` (`folder_id`,`uid`,`msguid`)
) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
DROP TABLE IF EXISTS `kolab_cache_configuration`;
CREATE TABLE `kolab_cache_configuration` (
`folder_id` BIGINT UNSIGNED NOT NULL,
`msguid` BIGINT UNSIGNED NOT NULL,
`uid` VARCHAR(512) NOT NULL,
`created` DATETIME DEFAULT NULL,
`changed` DATETIME DEFAULT NULL,
`data` LONGTEXT NOT NULL,
`tags` TEXT NOT NULL,
`words` TEXT NOT NULL,
`type` VARCHAR(32) CHARACTER SET ascii NOT NULL,
CONSTRAINT `fk_kolab_cache_configuration_folder` FOREIGN KEY (`folder_id`)
REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE,
PRIMARY KEY(`folder_id`,`msguid`),
INDEX `configuration_type` (`folder_id`,`type`),
INDEX `configuration_uid2msguid` (`folder_id`,`uid`,`msguid`)
) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
DROP TABLE IF EXISTS `kolab_cache_freebusy`;
CREATE TABLE `kolab_cache_freebusy` (
`folder_id` BIGINT UNSIGNED NOT NULL,
`msguid` BIGINT UNSIGNED NOT NULL,
`uid` VARCHAR(512) NOT NULL,
`created` DATETIME DEFAULT NULL,
`changed` DATETIME DEFAULT NULL,
`data` LONGTEXT NOT NULL,
`tags` TEXT NOT NULL,
`words` TEXT NOT NULL,
`dtstart` DATETIME,
`dtend` DATETIME,
CONSTRAINT `fk_kolab_cache_freebusy_folder` FOREIGN KEY (`folder_id`)
REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE,
PRIMARY KEY(`folder_id`,`msguid`),
INDEX `freebusy_uid2msguid` (`folder_id`,`uid`,`msguid`)
) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+DROP TABLE IF EXISTS `kolab_cache_dav_event`;
+
+CREATE TABLE `kolab_cache_dav_event` (
+ `folder_id` BIGINT UNSIGNED NOT NULL,
+ `uid` VARCHAR(512) NOT NULL,
+ `created` DATETIME DEFAULT NULL,
+ `changed` DATETIME DEFAULT NULL,
+ `data` LONGTEXT NOT NULL,
+ `tags` TEXT NOT NULL,
+ `words` TEXT NOT NULL,
+ `dtstart` DATETIME,
+ `dtend` DATETIME,
+ CONSTRAINT `fk_kolab_cache_dav_event_folder` FOREIGN KEY (`folder_id`)
+ REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE,
+ PRIMARY KEY(`folder_id`,`uid`)
+) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
SET FOREIGN_KEY_CHECKS=1;
-REPLACE INTO `system` (`name`, `value`) VALUES ('libkolab-version', '2021101100');
+REPLACE INTO `system` (`name`, `value`) VALUES ('libkolab-version', '2022100500');
diff --git a/plugins/libkolab/lib/kolab_dav_client.php b/plugins/libkolab/lib/kolab_dav_client.php
new file mode 100644
index 00000000..dba20edc
--- /dev/null
+++ b/plugins/libkolab/lib/kolab_dav_client.php
@@ -0,0 +1,450 @@
+<?php
+
+/**
+ * A *DAV client.
+ *
+ * @author Aleksander Machniak <machniak@apheleia-it.ch>
+ *
+ * Copyright (C) 2022, Apheleia IT AG <contact@apheleia-it.ch>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+class kolab_dav_client
+{
+ public $url;
+
+ protected $rc;
+ protected $responseHeaders = [];
+
+ /**
+ * Object constructor
+ */
+ public function __construct($url)
+ {
+ $this->url = $url;
+ $this->rc = rcube::get_instance();
+ }
+
+ /**
+ * Execute HTTP request to a DAV server
+ */
+ protected function request($path, $method, $body = '', $headers = [])
+ {
+ $rcube = rcube::get_instance();
+ $debug = (array) $rcube->config->get('dav_debug');
+
+ $request_config = [
+ 'store_body' => true,
+ 'follow_redirects' => true,
+ ];
+
+ $this->responseHeaders = [];
+
+ if ($path && ($rootPath = parse_url($this->url, PHP_URL_PATH)) && strpos($path, $rootPath) === 0) {
+ $path = substr($path, strlen($rootPath));
+ }
+
+ try {
+
+ $request = $this->initRequest($this->url . $path, $method, $request_config);
+
+ $request->setAuth($this->rc->user->get_username(), $this->rc->decrypt($_SESSION['password']));
+
+ if ($body) {
+ $request->setBody($body);
+ $request->setHeader(['Content-Type' => 'application/xml; charset=utf-8']);
+ }
+
+ if (!empty($headers)) {
+ $request->setHeader($headers);
+ }
+
+ if ($debug) {
+ rcube::write_log('dav', "C: {$method}: " . (string) $request->getUrl()
+ . "\n" . $this->debugBody($body, $request->getHeaders()));
+ }
+
+ $response = $request->send();
+
+ $body = $response->getBody();
+ $code = $response->getStatus();
+
+ if ($debug) {
+ rcube::write_log('dav', "S: [{$code}]\n" . $this->debugBody($body, $response->getHeader()));
+ }
+
+ if ($code >= 300) {
+ throw new Exception("DAV Error ($code):\n{$body}");
+ }
+
+ $this->responseHeaders = $response->getHeader();
+
+ return $this->parseXML($body);
+ }
+ catch (Exception $e) {
+ rcube::raise_error($e, true, false);
+ return false;
+ }
+ }
+
+ /**
+ * Discover DAV folders of specified type on the server
+ */
+ public function discover($component = 'VEVENT')
+ {
+/*
+ $path = parse_url($this->url, PHP_URL_PATH);
+
+ $body = '<?xml version="1.0" encoding="utf-8"?>'
+ . '<d:propfind xmlns:d="DAV:">'
+ . '<d:prop>'
+ . '<d:current-user-principal />'
+ . '</d:prop>'
+ . '</d:propfind>';
+
+ $response = $this->request('/calendars', 'PROPFIND', $body);
+
+ $elements = $response->getElementsByTagName('response');
+
+ foreach ($elements as $element) {
+ foreach ($element->getElementsByTagName('prop') as $prop) {
+ $principal_href = $prop->nodeValue;
+ break;
+ }
+ }
+
+ if ($path && strpos($principal_href, $path) === 0) {
+ $principal_href = substr($principal_href, strlen($path));
+ }
+
+ $body = '<?xml version="1.0" encoding="utf-8"?>'
+ . '<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">'
+ . '<d:prop>'
+ . '<c:calendar-home-set />'
+ . '</d:prop>'
+ . '</d:propfind>';
+
+ $response = $this->request($principal_href, 'PROPFIND', $body);
+*/
+ $roots = [
+ 'VEVENT' => 'calendars',
+ 'VTODO' => 'calendars',
+ 'VCARD' => 'addressbooks',
+ ];
+
+ $principal_href = '/' . $roots[$component] . '/' . $this->rc->user->get_username();
+
+ $body = '<?xml version="1.0" encoding="utf-8"?>'
+ . '<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:a="http://apple.com/ns/ical/">'
+ . '<d:prop>'
+ . '<d:resourcetype />'
+ . '<d:displayname />'
+ . '<cs:getctag />'
+ . '<c:supported-calendar-component-set />'
+ . '<a:calendar-color />'
+ . '</d:prop>'
+ . '</d:propfind>';
+
+ $response = $this->request($principal_href, 'PROPFIND', $body);
+
+ if (empty($response)) {
+ return false;
+ }
+
+ $folders = [];
+
+ foreach ($response->getElementsByTagName('response') as $element) {
+ $folder = $this->getFolderPropertiesFromResponse($element);
+ if ($folder['type'] === $component) {
+ $folders[] = $folder;
+ }
+ }
+
+ return $folders;
+ }
+
+ /**
+ * Create DAV object in a folder
+ */
+ public function create($location, $content)
+ {
+ $response = $this->request($location, 'PUT', $content, ['Content-Type' => 'text/calendar; charset=utf-8']);
+
+ if ($response !== false) {
+ $etag = $this->responseHeaders['etag'];
+
+ if (preg_match('|^".*"$|', $etag)) {
+ $etag = substr($etag, 1, -1);
+ }
+
+ return $etag;
+ }
+
+ return false;
+ }
+
+ /**
+ * Delete DAV object from a folder
+ */
+ public function delete($location)
+ {
+ $response = $this->request($location, 'DELETE', '', ['Depth' => 1, 'Prefer' => 'return-minimal']);
+
+ return $response !== false;
+ }
+
+ /**
+ * Fetch DAV objects metadata (ETag, href) a folder
+ */
+ public function getIndex($location, $component = 'VEVENT')
+ {
+ $body = '<?xml version="1.0" encoding="utf-8"?>'
+ .' <c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">'
+ . '<d:prop>'
+ . '<d:getetag />'
+ . '</d:prop>'
+ . '<c:filter>'
+ . '<c:comp-filter name="VCALENDAR">'
+ . '<c:comp-filter name="' . $component . '" />'
+ . '</c:comp-filter>'
+ . '</c:filter>'
+ . '</c:calendar-query>';
+
+ $response = $this->request($location, 'REPORT', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']);
+
+ if (empty($response)) {
+ return false;
+ }
+
+ $objects = [];
+
+ foreach ($response->getElementsByTagName('response') as $element) {
+ $objects[] = $this->getObjectPropertiesFromResponse($element);
+ }
+
+ return $objects;
+ }
+
+ /**
+ * Fetch DAV objects data from a folder
+ */
+ public function getData($location, $hrefs = [])
+ {
+ if (empty($hrefs)) {
+ return [];
+ }
+
+ $body = '';
+ foreach ($hrefs as $href) {
+ $body .= '<d:href>' . $href . '</d:href>';
+ }
+
+ $body = '<?xml version="1.0" encoding="utf-8"?>'
+ .' <c:calendar-multiget xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">'
+ . '<d:prop>'
+ . '<d:getetag />'
+ . '<c:calendar-data />'
+ . '</d:prop>'
+ . $body
+ . '</c:calendar-multiget>';
+
+ $response = $this->request($location, 'REPORT', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']);
+
+ if (empty($response)) {
+ return false;
+ }
+
+ $objects = [];
+
+ foreach ($response->getElementsByTagName('response') as $element) {
+ $objects[] = $this->getObjectPropertiesFromResponse($element);
+ }
+
+ return $objects;
+ }
+
+ /**
+ * Parse XML content
+ */
+ protected function parseXML($xml)
+ {
+ $doc = new DOMDocument('1.0', 'UTF-8');
+
+ if (stripos($xml, '<?xml') === 0) {
+ if (!$doc->loadXML($xml)) {
+ throw new Exception("Failed to parse XML");
+ }
+
+ $doc->formatOutput = true;
+ }
+
+ return $doc;
+ }
+
+ /**
+ * Parse request/response body for debug purposes
+ */
+ protected function debugBody($body, $headers)
+ {
+ $head = '';
+ foreach ($headers as $header_name => $header_value) {
+ $head .= "{$header_name}: {$header_value}\n";
+ }
+
+ if (stripos($body, '<?xml') === 0) {
+ $doc = new DOMDocument('1.0', 'UTF-8');
+
+ if (!$doc->loadXML($body)) {
+ throw new Exception("Failed to parse XML");
+ }
+
+ $doc->formatOutput = true;
+
+ $body = $doc->saveXML();
+ }
+
+ return $head . "\n" . rtrim($body);
+ }
+
+ /**
+ * Extract folder properties from a server 'response' element
+ */
+ protected function getFolderPropertiesFromResponse(DOMNode $element)
+ {
+
+ if ($href = $element->getElementsByTagName('href')->item(0)) {
+ $href = $href->nodeValue;
+/*
+ $path = parse_url($this->url, PHP_URL_PATH);
+ if ($path && strpos($href, $path) === 0) {
+ $href = substr($href, strlen($path));
+ }
+*/
+ }
+
+ if ($color = $element->getElementsByTagName('calendar-color')->item(0)) {
+ if (preg_match('/^#[0-9A-F]{8}$/', $color->nodeValue)) {
+ $color = substr($color->nodeValue, 1, -2);
+ } else {
+ $color = null;
+ }
+ }
+
+ if ($name = $element->getElementsByTagName('displayname')->item(0)) {
+ $name = $name->nodeValue;
+ }
+
+ if ($ctag = $element->getElementsByTagName('getctag')->item(0)) {
+ $ctag = $ctag->nodeValue;
+ }
+
+ $component = null;
+ if ($set_element = $element->getElementsByTagName('supported-calendar-component-set')->item(0)) {
+ if ($comp_element = $set_element->getElementsByTagName('comp')->item(0)) {
+ $component = $comp_element->attributes->getNamedItem('name')->nodeValue;
+ }
+ }
+
+ return [
+ 'href' => $href,
+ 'name' => $name,
+ 'ctag' => $ctag,
+ 'color' => $color,
+ 'type' => $component,
+ ];
+ }
+
+ /**
+ * Extract object properties from a server 'response' element
+ */
+ protected function getObjectPropertiesFromResponse(DOMNode $element)
+ {
+ $uid = null;
+ if ($href = $element->getElementsByTagName('href')->item(0)) {
+ $href = $href->nodeValue;
+/*
+ $path = parse_url($this->url, PHP_URL_PATH);
+ if ($path && strpos($href, $path) === 0) {
+ $href = substr($href, strlen($path));
+ }
+*/
+ // Extract UID from the URL
+ $href_parts = explode('/', $href);
+ $uid = preg_replace('/\.[a-z]+$/', '', $href_parts[count($href_parts)-1]);
+ }
+
+ if ($data = $element->getElementsByTagName('calendar-data')->item(0)) {
+ $data = $data->nodeValue;
+ }
+
+ if ($etag = $element->getElementsByTagName('getetag')->item(0)) {
+ $etag = $etag->nodeValue;
+ if (preg_match('|^".*"$|', $etag)) {
+ $etag = substr($etag, 1, -1);
+ }
+ }
+
+ return [
+ 'href' => $href,
+ 'data' => $data,
+ 'etag' => $etag,
+ 'uid' => $uid,
+ ];
+ }
+
+ /**
+ * Initialize HTTP request object
+ */
+ protected function initRequest($url = '', $method = 'GET', $config = array())
+ {
+ $rcube = rcube::get_instance();
+ $http_config = (array) $rcube->config->get('kolab_http_request');
+
+ // deprecated configuration options
+ if (empty($http_config)) {
+ foreach (array('ssl_verify_peer', 'ssl_verify_host') as $option) {
+ $value = $rcube->config->get('kolab_' . $option, true);
+ if (is_bool($value)) {
+ $http_config[$option] = $value;
+ }
+ }
+ }
+
+ if (!empty($config)) {
+ $http_config = array_merge($http_config, $config);
+ }
+
+ // load HTTP_Request2
+ require_once 'HTTP/Request2.php';
+
+ try {
+ $request = new HTTP_Request2();
+ $request->setConfig($http_config);
+
+ // proxy User-Agent string
+ $request->setHeader('user-agent', $_SERVER['HTTP_USER_AGENT']);
+
+ // cleanup
+ $request->setBody('');
+ $request->setUrl($url);
+ $request->setMethod($method);
+
+ return $request;
+ }
+ catch (Exception $e) {
+ rcube::raise_error($e, true, true);
+ }
+ }
+}
diff --git a/plugins/libkolab/lib/kolab_storage_cache.php b/plugins/libkolab/lib/kolab_storage_cache.php
index 18b5e5f2..e9591484 100644
--- a/plugins/libkolab/lib/kolab_storage_cache.php
+++ b/plugins/libkolab/lib/kolab_storage_cache.php
@@ -1,1458 +1,1458 @@
<?php
/**
* Kolab storage cache class providing a local caching layer for Kolab groupware objects.
*
* @version @package_version@
* @author Thomas Bruederli <bruederli@kolabsys.com>
*
* Copyright (C) 2012-2013, Kolab Systems AG <contact@kolabsys.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
class kolab_storage_cache
{
const DB_DATE_FORMAT = 'Y-m-d H:i:s';
const MAX_RECORDS = 500;
protected $db;
protected $imap;
protected $folder;
protected $uid2msg;
protected $objects;
protected $metadata = array();
protected $folder_id;
protected $resource_uri;
protected $enabled = true;
protected $synched = false;
protected $synclock = false;
protected $ready = false;
protected $cache_table;
protected $folders_table;
protected $max_sql_packet;
protected $max_sync_lock_time = 600;
protected $extra_cols = array();
protected $data_props = array();
protected $order_by = null;
protected $limit = null;
protected $error = 0;
protected $server_timezone;
/**
* Factory constructor
*/
public static function factory(kolab_storage_folder $storage_folder)
{
$subclass = 'kolab_storage_cache_' . $storage_folder->type;
if (class_exists($subclass)) {
return new $subclass($storage_folder);
}
else {
rcube::raise_error(array(
'code' => 900,
'type' => 'php',
'message' => "No kolab_storage_cache class found for folder '$storage_folder->name' of type '$storage_folder->type'"
), true);
return new kolab_storage_cache($storage_folder);
}
}
/**
* Default constructor
*/
public function __construct(kolab_storage_folder $storage_folder = null)
{
$rcmail = rcube::get_instance();
$this->db = $rcmail->get_dbh();
$this->imap = $rcmail->get_storage();
$this->enabled = $rcmail->config->get('kolab_cache', false);
$this->folders_table = $this->db->table_name('kolab_folders');
$this->server_timezone = new DateTimeZone(date_default_timezone_get());
if ($this->enabled) {
// always read folder cache and lock state from DB master
$this->db->set_table_dsn('kolab_folders', 'w');
// remove sync-lock on script termination
$rcmail->add_shutdown_function(array($this, '_sync_unlock'));
}
if ($storage_folder)
$this->set_folder($storage_folder);
}
/**
* Direct access to cache by folder_id
* (only for internal use)
*/
public function select_by_id($folder_id)
{
- $sql_arr = $this->db->fetch_assoc($this->db->query("SELECT * FROM `{$this->folders_table}` WHERE `folder_id` = ?", $folder_id));
- if ($sql_arr) {
+ $query = $this->db->query("SELECT * FROM `{$this->folders_table}` WHERE `folder_id` = ?", $folder_id);
+ if ($sql_arr = $this->db->fetch_assoc($query)) {
$this->metadata = $sql_arr;
$this->folder_id = $sql_arr['folder_id'];
$this->folder = new StdClass;
$this->folder->type = $sql_arr['type'];
$this->resource_uri = $sql_arr['resource'];
$this->cache_table = $this->db->table_name('kolab_cache_' . $sql_arr['type']);
$this->ready = true;
}
}
/**
* Connect cache with a storage folder
*
* @param kolab_storage_folder The storage folder instance to connect with
*/
public function set_folder(kolab_storage_folder $storage_folder)
{
$this->folder = $storage_folder;
if (empty($this->folder->name) || !$this->folder->valid) {
$this->ready = false;
return;
}
// compose fully qualified ressource uri for this instance
$this->resource_uri = $this->folder->get_resource_uri();
$this->cache_table = $this->db->table_name('kolab_cache_' . $this->folder->type);
$this->ready = $this->enabled && !empty($this->folder->type);
$this->folder_id = null;
}
/**
* Returns true if this cache supports query by type
*/
public function has_type_col()
{
return in_array('type', $this->extra_cols);
}
/**
* Getter for the numeric ID used in cache tables
*/
public function get_folder_id()
{
$this->_read_folder_data();
return $this->folder_id;
}
/**
* Returns code of last error
*
* @return int Error code
*/
public function get_error()
{
return $this->error;
}
/**
* Synchronize local cache data with remote
*/
public function synchronize()
{
// only sync once per request cycle
if ($this->synched)
return;
if (!$this->ready) {
// kolab cache is disabled, synchronize IMAP mailbox cache only
$this->imap_mode(true);
$this->imap->folder_sync($this->folder->name);
$this->imap_mode(false);
}
else {
$this->sync_start = time();
// read cached folder metadata
$this->_read_folder_data();
// Read folder data from IMAP
$ctag = $this->folder->get_ctag();
// Validate current ctag
list($uidvalidity, $highestmodseq, $uidnext) = explode('-', $ctag);
if (empty($uidvalidity) || empty($highestmodseq)) {
rcube::raise_error(array(
'code' => 900,
'message' => "Failed to sync the kolab cache (Invalid ctag)"
), true);
}
// check cache status ($this->metadata is set in _read_folder_data())
else if (
empty($this->metadata['ctag'])
|| empty($this->metadata['changed'])
|| $this->metadata['ctag'] !== $ctag
) {
// lock synchronization for this folder or wait if locked
$this->_sync_lock();
// Run a full-sync (initial sync or continue the aborted sync)
if (empty($this->metadata['changed']) || empty($this->metadata['ctag'])) {
$result = $this->synchronize_full();
}
// Synchronize only the changes since last sync
else {
$result = $this->synchronize_update($ctag);
}
// update ctag value (will be written to database in _sync_unlock())
if ($result) {
$this->metadata['ctag'] = $ctag;
$this->metadata['changed'] = date(self::DB_DATE_FORMAT, time());
}
// remove lock
$this->_sync_unlock();
}
}
$this->check_error();
$this->synched = time();
}
/**
* Perform full cache synchronization
*/
protected function synchronize_full()
{
// get effective time limit we have for synchronization (~70% of the execution time)
$time_limit = $this->_max_sync_lock_time() * 0.7;
if (time() - $this->sync_start > $time_limit) {
return false;
}
// disable messages cache if configured to do so
$this->imap_mode(true);
// synchronize IMAP mailbox cache, does nothing if messages cache is disabled
$this->imap->folder_sync($this->folder->name);
// compare IMAP index with object cache index
$imap_index = $this->imap->index($this->folder->name, null, null, true, true);
$this->imap_mode(false);
if ($imap_index->is_error()) {
rcube::raise_error(array(
'code' => 900,
'message' => "Failed to sync the kolab cache (SEARCH failed)"
), true);
return false;
}
// determine objects to fetch or to invalidate
$imap_index = $imap_index->get();
$del_index = array();
$old_index = $this->current_index($del_index);
// Fetch objects and store in DB
$result = $this->synchronize_fetch($imap_index, $old_index, $del_index);
if ($result) {
// Remove redundant entries from IMAP and cache
$rem_index = array_intersect($del_index, $imap_index);
$del_index = array_merge(array_unique($del_index), array_diff($old_index, $imap_index));
$this->synchronize_delete($rem_index, $del_index);
}
return $result;
}
/**
* Perform partial cache synchronization, based on QRESYNC
*/
protected function synchronize_update()
{
if (!$this->imap->get_capability('QRESYNC')) {
rcube::raise_error(array(
'code' => 900,
'message' => "Failed to sync the kolab cache (no QRESYNC capability)"
), true);
return $this->synchronize_full();
}
// Handle the previous ctag
list($uidvalidity, $highestmodseq, $uidnext) = explode('-', $this->metadata['ctag']);
if (empty($uidvalidity) || empty($highestmodseq)) {
rcube::raise_error(array(
'code' => 900,
'message' => "Failed to sync the kolab cache (Invalid old ctag)"
), true);
return false;
}
// Enable QRESYNC
$res = $this->imap->conn->enable('QRESYNC');
if ($res === false) {
rcube::raise_error(array(
'code' => 900,
'message' => "Failed to sync the kolab cache (failed to enable QRESYNC/CONDSTORE)"
), true);
return false;
}
$mbox_data = $this->imap->folder_data($this->folder->name);
if (empty($mbox_data)) {
rcube::raise_error(array(
'code' => 900,
'message' => "Failed to sync the kolab cache (failed to get folder state)"
), true);
return false;
}
// Check UIDVALIDITY
if ($uidvalidity != $mbox_data['UIDVALIDITY']) {
return $this->synchronize_full();
}
// QRESYNC not supported on specified mailbox
if (!empty($mbox_data['NOMODSEQ']) || empty($mbox_data['HIGHESTMODSEQ'])) {
rcube::raise_error(array(
'code' => 900,
'message' => "Failed to sync the kolab cache (QRESYNC not supported on the folder)"
), true);
return $this->synchronize_full();
}
// Get modified flags and vanished messages
// UID FETCH 1:* (FLAGS) (CHANGEDSINCE 0123456789 VANISHED)
$result = $this->imap->conn->fetch(
$this->folder->name, '1:*', true, array('FLAGS'), $highestmodseq, true
);
$removed = array();
$modified = array();
$existing = $this->current_index($removed);
if (!empty($result)) {
foreach ($result as $msg) {
$uid = $msg->uid;
// Message marked as deleted
if (!empty($msg->flags['DELETED'])) {
$removed[] = $uid;
continue;
}
// Flags changed or new
$modified[] = $uid;
}
}
$new = array_diff($modified, $existing, $removed);
$result = true;
if (!empty($new)) {
$result = $this->synchronize_fetch($new, $existing, $removed);
if (!$result) {
return false;
}
}
// VANISHED found?
$mbox_data = $this->imap->folder_data($this->folder->name);
// Removed vanished messages from the database
$vanished = (array) rcube_imap_generic::uncompressMessageSet($mbox_data['VANISHED']);
// Remove redundant entries from IMAP and DB
$vanished = array_merge($removed, array_intersect($vanished, $existing));
$this->synchronize_delete($removed, $vanished);
return $result;
}
/**
* Fetch objects from IMAP and save into the database
*/
protected function synchronize_fetch($new_index, &$old_index, &$del_index)
{
// get effective time limit we have for synchronization (~70% of the execution time)
$time_limit = $this->_max_sync_lock_time() * 0.7;
if (time() - $this->sync_start > $time_limit) {
return false;
}
$i = 0;
$aborted = false;
// fetch new objects from imap
foreach (array_diff($new_index, $old_index) as $msguid) {
// Note: We'll store only objects matching the folder type
// anything else will be silently ignored
if ($object = $this->folder->read_object($msguid)) {
// Deduplication: remove older objects with the same UID
// Here we do not resolve conflicts, we just make sure
// the most recent version of the object will be used
if ($old_msguid = $old_index[$object['uid']]) {
if ($old_msguid < $msguid) {
$del_index[] = $old_msguid;
}
else {
$del_index[] = $msguid;
continue;
}
}
$old_index[$object['uid']] = $msguid;
$this->_extended_insert($msguid, $object);
// check time limit and abort sync if running too long
if (++$i % 50 == 0 && time() - $this->sync_start > $time_limit) {
$aborted = true;
break;
}
}
}
$this->_extended_insert(0, null);
return $aborted === false;
}
/**
* Remove specified objects from the database and IMAP
*/
protected function synchronize_delete($imap_delete, $db_delete)
{
if (!empty($imap_delete)) {
$this->imap_mode(true);
$this->imap->delete_message($imap_delete, $this->folder->name);
$this->imap_mode(false);
}
if (!empty($db_delete)) {
$quoted_ids = join(',', array_map(array($this->db, 'quote'), $db_delete));
$this->db->query(
"DELETE FROM `{$this->cache_table}` WHERE `folder_id` = ? AND `msguid` IN ($quoted_ids)",
$this->folder_id
);
}
}
/**
* Return current use->msguid index
*/
protected function current_index(&$duplicates = array())
{
// read cache index
$sql_result = $this->db->query(
"SELECT `msguid`, `uid` FROM `{$this->cache_table}` WHERE `folder_id` = ?"
. " ORDER BY `msguid` DESC", $this->folder_id
);
$index = $del_index = array();
while ($sql_arr = $this->db->fetch_assoc($sql_result)) {
// Mark all duplicates for removal (note sorting order above)
// Duplicates here should not happen, but they do sometimes
if (isset($index[$sql_arr['uid']])) {
$duplicates[] = $sql_arr['msguid'];
}
else {
$index[$sql_arr['uid']] = $sql_arr['msguid'];
}
}
return $index;
}
/**
* Read a single entry from cache or from IMAP directly
*
* @param string Related IMAP message UID
* @param string Object type to read
* @param string IMAP folder name the entry relates to
* @param array Hash array with object properties or null if not found
*/
public function get($msguid, $type = null, $foldername = null)
{
// delegate to another cache instance
if ($foldername && $foldername != $this->folder->name) {
$success = false;
if ($targetfolder = kolab_storage::get_folder($foldername)) {
$success = $targetfolder->cache->get($msguid, $type);
$this->error = $targetfolder->cache->get_error();
}
return $success;
}
// load object if not in memory
if (!isset($this->objects[$msguid])) {
if ($this->ready) {
$this->_read_folder_data();
$sql_result = $this->db->query(
"SELECT * FROM `{$this->cache_table}` ".
"WHERE `folder_id` = ? AND `msguid` = ?",
$this->folder_id,
$msguid
);
if ($sql_arr = $this->db->fetch_assoc($sql_result)) {
$this->objects = array($msguid => $this->_unserialize($sql_arr)); // store only this object in memory (#2827)
}
}
// fetch from IMAP if not present in cache
if (empty($this->objects[$msguid])) {
if ($object = $this->folder->read_object($msguid, $type ?: '*', $foldername)) {
$this->objects = array($msguid => $object);
$this->set($msguid, $object);
}
}
}
$this->check_error();
return $this->objects[$msguid];
}
/**
* Getter for a single Kolab object identified by its UID
*
* @param string $uid Object UID
*
* @return array The Kolab object represented as hash array
*/
public function get_by_uid($uid)
{
$old_order_by = $this->order_by;
$old_limit = $this->limit;
// set order to make sure we get most recent object version
// set limit to skip count query
$this->order_by = '`msguid` DESC';
$this->limit = array(1, 0);
$list = $this->select(array(array('uid', '=', $uid)));
// set the order/limit back to defined value
$this->order_by = $old_order_by;
$this->limit = $old_limit;
if (!empty($list) && !empty($list[0])) {
return $list[0];
}
}
/**
* Insert/Update a cache entry
*
* @param string Related IMAP message UID
* @param mixed Hash array with object properties to save or false to delete the cache entry
* @param string IMAP folder name the entry relates to
*/
public function set($msguid, $object, $foldername = null)
{
if (!$msguid) {
return;
}
// delegate to another cache instance
if ($foldername && $foldername != $this->folder->name) {
if ($targetfolder = kolab_storage::get_folder($foldername)) {
$targetfolder->cache->set($msguid, $object);
$this->error = $targetfolder->cache->get_error();
}
return;
}
// remove old entry
if ($this->ready) {
$this->_read_folder_data();
$this->db->query("DELETE FROM `{$this->cache_table}` WHERE `folder_id` = ? AND `msguid` = ?",
$this->folder_id, $msguid);
}
if ($object) {
// insert new object data...
$this->save($msguid, $object);
}
else {
// ...or set in-memory cache to false
$this->objects[$msguid] = $object;
}
$this->check_error();
}
/**
* Insert (or update) a cache entry
*
* @param int Related IMAP message UID
* @param mixed Hash array with object properties to save or false to delete the cache entry
* @param int Optional old message UID (for update)
*/
public function save($msguid, $object, $olduid = null)
{
// write to cache
if ($this->ready) {
$this->_read_folder_data();
$sql_data = $this->_serialize($object);
$sql_data['folder_id'] = $this->folder_id;
$sql_data['msguid'] = $msguid;
$sql_data['uid'] = $object['uid'];
$args = array();
$cols = array('folder_id', 'msguid', 'uid', 'changed', 'data', 'tags', 'words');
$cols = array_merge($cols, $this->extra_cols);
foreach ($cols as $idx => $col) {
$cols[$idx] = $this->db->quote_identifier($col);
$args[] = $sql_data[$col];
}
if ($olduid) {
foreach ($cols as $idx => $col) {
$cols[$idx] = "$col = ?";
}
$query = "UPDATE `{$this->cache_table}` SET " . implode(', ', $cols)
. " WHERE `folder_id` = ? AND `msguid` = ?";
$args[] = $this->folder_id;
$args[] = $olduid;
}
else {
$query = "INSERT INTO `{$this->cache_table}` (`created`, " . implode(', ', $cols)
. ") VALUES (" . $this->db->now() . str_repeat(', ?', count($cols)) . ")";
}
$result = $this->db->query($query, $args);
if (!$this->db->affected_rows($result)) {
rcube::raise_error(array(
'code' => 900, 'type' => 'php',
'message' => "Failed to write to kolab cache"
), true);
}
}
// keep a copy in memory for fast access
$this->objects = array($msguid => $object);
$this->uid2msg = array($object['uid'] => $msguid);
$this->check_error();
}
/**
* Move an existing cache entry to a new resource
*
* @param string Entry's IMAP message UID
* @param string Entry's Object UID
* @param kolab_storage_folder Target storage folder instance
* @param string Target entry's IMAP message UID
*/
public function move($msguid, $uid, $target, $new_msguid = null)
{
if ($this->ready && $target) {
// clear cached uid mapping and force new lookup
unset($target->cache->uid2msg[$uid]);
// resolve new message UID in target folder
if (!$new_msguid) {
$new_msguid = $target->cache->uid2msguid($uid);
}
if ($new_msguid) {
$this->_read_folder_data();
$this->db->query(
"UPDATE `{$this->cache_table}` SET `folder_id` = ?, `msguid` = ? ".
"WHERE `folder_id` = ? AND `msguid` = ?",
$target->cache->get_folder_id(),
$new_msguid,
$this->folder_id,
$msguid
);
$result = $this->db->affected_rows();
}
}
if (empty($result)) {
// just clear cache entry
$this->set($msguid, false);
}
unset($this->uid2msg[$uid]);
$this->check_error();
}
/**
* Remove all objects from local cache
*/
public function purge()
{
if (!$this->ready) {
return true;
}
$this->_read_folder_data();
$result = $this->db->query(
"DELETE FROM `{$this->cache_table}` WHERE `folder_id` = ?",
$this->folder_id
);
return $this->db->affected_rows($result);
}
/**
* Update resource URI for existing cache entries
*
* @param string Target IMAP folder to move it to
*/
public function rename($new_folder)
{
if (!$this->ready) {
return;
}
if ($target = kolab_storage::get_folder($new_folder)) {
// resolve new message UID in target folder
$this->db->query(
"UPDATE `{$this->folders_table}` SET `resource` = ? ".
"WHERE `resource` = ?",
$target->get_resource_uri(),
$this->resource_uri
);
$this->check_error();
}
else {
$this->error = kolab_storage::ERROR_IMAP_CONN;
}
}
/**
* Select Kolab objects filtered by the given query
*
* @param array Pseudo-SQL query as list of filter parameter triplets
* triplet: array('<colname>', '<comparator>', '<value>')
* @param boolean Set true to only return UIDs instead of complete objects
* @param boolean Use fast mode to fetch only minimal set of information
* (no xml fetching and parsing, etc.)
*
* @return array List of Kolab data objects (each represented as hash array) or UIDs
*/
public function select($query = array(), $uids = false, $fast = false)
{
$result = $uids ? array() : new kolab_storage_dataset($this);
// read from local cache DB (assume it to be synchronized)
if ($this->ready) {
$this->_read_folder_data();
// fetch full object data on one query if a small result set is expected
$fetchall = !$uids && ($this->limit ? $this->limit[0] : ($count = $this->count($query))) < self::MAX_RECORDS;
// skip SELECT if we know it will return nothing
if ($count === 0) {
return $result;
}
$sql_query = "SELECT " . ($fetchall ? '*' : "`msguid` AS `_msguid`, `uid`")
. " FROM `{$this->cache_table}` WHERE `folder_id` = ?"
. $this->_sql_where($query)
. (!empty($this->order_by) ? " ORDER BY " . $this->order_by : '');
$sql_result = $this->limit ?
$this->db->limitquery($sql_query, $this->limit[1], $this->limit[0], $this->folder_id) :
$this->db->query($sql_query, $this->folder_id);
if ($this->db->is_error($sql_result)) {
if ($uids) {
return null;
}
$result->set_error(true);
return $result;
}
while ($sql_arr = $this->db->fetch_assoc($sql_result)) {
if ($fast) {
$sql_arr['fast-mode'] = true;
}
if ($uids) {
$this->uid2msg[$sql_arr['uid']] = $sql_arr['_msguid'];
$result[] = $sql_arr['uid'];
}
else if ($fetchall && ($object = $this->_unserialize($sql_arr))) {
$result[] = $object;
}
else if (!$fetchall) {
// only add msguid to dataset index
$result[] = $sql_arr;
}
}
}
// use IMAP
else {
$filter = $this->_query2assoc($query);
$this->imap_mode(true);
if ($filter['type']) {
$search = 'UNDELETED HEADER X-Kolab-Type ' . kolab_format::KTYPE_PREFIX . $filter['type'];
$index = $this->imap->search_once($this->folder->name, $search);
}
else {
$index = $this->imap->index($this->folder->name, null, null, true, true);
}
$this->imap_mode(false);
if ($index->is_error()) {
$this->check_error();
if ($uids) {
return null;
}
$result->set_error(true);
return $result;
}
$index = $index->get();
$result = $uids ? $index : $this->_fetch($index, $filter['type']);
// TODO: post-filter result according to query
}
// We don't want to cache big results in-memory, however
// if we select only one object here, there's a big chance we will need it later
if (!$uids && count($result) == 1) {
if ($msguid = $result[0]['_msguid']) {
$this->uid2msg[$result[0]['uid']] = $msguid;
$this->objects = array($msguid => $result[0]);
}
}
$this->check_error();
return $result;
}
/**
* Get number of objects mathing the given query
*
* @param array $query Pseudo-SQL query as list of filter parameter triplets
* @return integer The number of objects of the given type
*/
public function count($query = array())
{
// read from local cache DB (assume it to be synchronized)
if ($this->ready) {
$this->_read_folder_data();
$sql_result = $this->db->query(
"SELECT COUNT(*) AS `numrows` FROM `{$this->cache_table}` ".
"WHERE `folder_id` = ?" . $this->_sql_where($query),
$this->folder_id
);
if ($this->db->is_error($sql_result)) {
return null;
}
$sql_arr = $this->db->fetch_assoc($sql_result);
$count = intval($sql_arr['numrows']);
}
// use IMAP
else {
$filter = $this->_query2assoc($query);
$this->imap_mode(true);
if ($filter['type']) {
$search = 'UNDELETED HEADER X-Kolab-Type ' . kolab_format::KTYPE_PREFIX . $filter['type'];
$index = $this->imap->search_once($this->folder->name, $search);
}
else {
$index = $this->imap->index($this->folder->name, null, null, true, true);
}
$this->imap_mode(false);
if ($index->is_error()) {
$this->check_error();
return null;
}
// TODO: post-filter result according to query
$count = $index->count();
}
$this->check_error();
return $count;
}
/**
* Define ORDER BY clause for cache queries
*/
public function set_order_by($sortcols)
{
if (!empty($sortcols)) {
$sortcols = array_map(function($v) {
list($column, $order) = explode(' ', $v, 2);
return "`$column`" . ($order ? " $order" : '');
}, (array) $sortcols);
$this->order_by = join(', ', $sortcols);
}
else {
$this->order_by = null;
}
}
/**
* Define LIMIT clause for cache queries
*/
public function set_limit($length, $offset = 0)
{
$this->limit = array($length, $offset);
}
/**
* Helper method to compose a valid SQL query from pseudo filter triplets
*/
protected function _sql_where($query)
{
$sql_where = '';
foreach ((array) $query as $param) {
if (is_array($param[0])) {
$subq = array();
foreach ($param[0] as $q) {
$subq[] = preg_replace('/^\s*AND\s+/i', '', $this->_sql_where(array($q)));
}
if (!empty($subq)) {
$sql_where .= ' AND (' . implode($param[1] == 'OR' ? ' OR ' : ' AND ', $subq) . ')';
}
continue;
}
else if ($param[1] == '=' && is_array($param[2])) {
$qvalue = '(' . join(',', array_map(array($this->db, 'quote'), $param[2])) . ')';
$param[1] = 'IN';
}
else if ($param[1] == '~' || $param[1] == 'LIKE' || $param[1] == '!~' || $param[1] == '!LIKE') {
$not = ($param[1] == '!~' || $param[1] == '!LIKE') ? 'NOT ' : '';
$param[1] = $not . 'LIKE';
$qvalue = $this->db->quote('%'.preg_replace('/(^\^|\$$)/', ' ', $param[2]).'%');
}
else if ($param[1] == '~*' || $param[1] == '!~*') {
$not = $param[1][1] == '!' ? 'NOT ' : '';
$param[1] = $not . 'LIKE';
$qvalue = $this->db->quote(preg_replace('/(^\^|\$$)/', ' ', $param[2]).'%');
}
else if ($param[0] == 'tags') {
$param[1] = ($param[1] == '!=' ? 'NOT ' : '' ) . 'LIKE';
$qvalue = $this->db->quote('% '.$param[2].' %');
}
else {
$qvalue = $this->db->quote($param[2]);
}
$sql_where .= sprintf(' AND %s %s %s',
$this->db->quote_identifier($param[0]),
$param[1],
$qvalue
);
}
return $sql_where;
}
/**
* Helper method to convert the given pseudo-query triplets into
* an associative filter array with 'equals' values only
*/
protected function _query2assoc($query)
{
// extract object type from query parameter
$filter = array();
foreach ($query as $param) {
if ($param[1] == '=')
$filter[$param[0]] = $param[2];
}
return $filter;
}
/**
* Fetch messages from IMAP
*
* @param array List of message UIDs to fetch
* @param string Requested object type or * for all
* @param string IMAP folder to read from
* @return array List of parsed Kolab objects
*/
protected function _fetch($index, $type = null, $folder = null)
{
$results = new kolab_storage_dataset($this);
foreach ((array)$index as $msguid) {
if ($object = $this->folder->read_object($msguid, $type, $folder)) {
$results[] = $object;
$this->set($msguid, $object);
}
}
return $results;
}
/**
* Helper method to convert the given Kolab object into a dataset to be written to cache
*/
protected function _serialize($object)
{
$data = array();
$sql_data = array('changed' => null, 'tags' => '', 'words' => '');
if ($object['changed']) {
$sql_data['changed'] = date(self::DB_DATE_FORMAT, is_object($object['changed']) ? $object['changed']->format('U') : $object['changed']);
}
if ($object['_formatobj']) {
$xml = (string) $object['_formatobj']->write(3.0);
$data['_size'] = strlen($xml);
$sql_data['tags'] = ' ' . join(' ', $object['_formatobj']->get_tags()) . ' '; // pad with spaces for strict/prefix search
$sql_data['words'] = ' ' . join(' ', $object['_formatobj']->get_words()) . ' ';
}
// Store only minimal set of object properties
foreach ($this->data_props as $prop) {
if (isset($object[$prop])) {
$data[$prop] = $object[$prop];
if ($data[$prop] instanceof DateTime) {
$data[$prop] = array(
'cl' => 'DateTime',
'dt' => $data[$prop]->format('Y-m-d H:i:s'),
'tz' => $data[$prop]->getTimezone()->getName(),
);
}
}
}
$sql_data['data'] = json_encode(rcube_charset::clean($data));
return $sql_data;
}
/**
* Helper method to turn stored cache data into a valid storage object
*/
protected function _unserialize($sql_arr)
{
if ($sql_arr['fast-mode'] && !empty($sql_arr['data']) && ($object = json_decode($sql_arr['data'], true))) {
$object['uid'] = $sql_arr['uid'];
foreach ($this->data_props as $prop) {
if (isset($object[$prop]) && is_array($object[$prop]) && $object[$prop]['cl'] == 'DateTime') {
$object[$prop] = new DateTime($object[$prop]['dt'], new DateTimeZone($object[$prop]['tz']));
}
else if (!isset($object[$prop]) && isset($sql_arr[$prop])) {
$object[$prop] = $sql_arr[$prop];
}
}
if ($sql_arr['created'] && empty($object['created'])) {
$object['created'] = new DateTime($sql_arr['created']);
}
if ($sql_arr['changed'] && empty($object['changed'])) {
$object['changed'] = new DateTime($sql_arr['changed']);
}
$object['_type'] = $sql_arr['type'] ?: $this->folder->type;
$object['_msguid'] = $sql_arr['msguid'];
$object['_mailbox'] = $this->folder->name;
}
// Fetch object xml
else {
// FIXME: Because old cache solution allowed storing objects that
// do not match folder type we may end up with invalid objects.
// 2nd argument of read_object() here makes sure they are still
// usable. However, not allowing them here might be also an intended
// solution in future.
$object = $this->folder->read_object($sql_arr['msguid'], '*');
}
return $object;
}
/**
* Write records into cache using extended inserts to reduce the number of queries to be executed
*
* @param int Message UID. Set 0 to commit buffered inserts
* @param array Kolab object to cache
*/
protected function _extended_insert($msguid, $object)
{
static $buffer = '';
$line = '';
$cols = array('folder_id', 'msguid', 'uid', 'created', 'changed', 'data', 'tags', 'words');
if ($this->extra_cols) {
$cols = array_merge($cols, $this->extra_cols);
}
if ($object) {
$sql_data = $this->_serialize($object);
// Skip multi-folder insert for all databases but MySQL
// In Oracle we can't put long data inline, others we don't support yet
if (strpos($this->db->db_provider, 'mysql') !== 0) {
$extra_args = array();
$params = array($this->folder_id, $msguid, $object['uid'], $sql_data['changed'],
$sql_data['data'], $sql_data['tags'], $sql_data['words']);
foreach ($this->extra_cols as $col) {
$params[] = $sql_data[$col];
$extra_args[] = '?';
}
$cols = implode(', ', array_map(function($n) { return "`{$n}`"; }, $cols));
$extra_args = count($extra_args) ? ', ' . implode(', ', $extra_args) : '';
$result = $this->db->query(
"INSERT INTO `{$this->cache_table}` ($cols)"
. " VALUES (?, ?, ?, " . $this->db->now() . ", ?, ?, ?, ?$extra_args)",
$params
);
if (!$this->db->affected_rows($result)) {
rcube::raise_error(array(
'code' => 900, 'message' => "Failed to write to kolab cache"
), true);
}
return;
}
$values = array(
$this->db->quote($this->folder_id),
$this->db->quote($msguid),
$this->db->quote($object['uid']),
$this->db->now(),
$this->db->quote($sql_data['changed']),
$this->db->quote($sql_data['data']),
$this->db->quote($sql_data['tags']),
$this->db->quote($sql_data['words']),
);
foreach ($this->extra_cols as $col) {
$values[] = $this->db->quote($sql_data[$col]);
}
$line = '(' . join(',', $values) . ')';
}
if ($buffer && (!$msguid || (strlen($buffer) + strlen($line) > $this->max_sql_packet()))) {
$columns = implode(', ', array_map(function($n) { return "`{$n}`"; }, $cols));
$update = implode(', ', array_map(function($i) { return "`{$i}` = VALUES(`{$i}`)"; }, array_slice($cols, 2)));
$result = $this->db->query(
"INSERT INTO `{$this->cache_table}` ($columns) VALUES $buffer"
. " ON DUPLICATE KEY UPDATE $update"
);
if (!$this->db->affected_rows($result)) {
rcube::raise_error(array(
'code' => 900, 'message' => "Failed to write to kolab cache"
), true);
}
$buffer = '';
}
$buffer .= ($buffer ? ',' : '') . $line;
}
/**
* Returns max_allowed_packet from mysql config
*/
protected function max_sql_packet()
{
if (!$this->max_sql_packet) {
// mysql limit or max 4 MB
$value = $this->db->get_variable('max_allowed_packet', 1048500);
$this->max_sql_packet = min($value, 4*1024*1024) - 2000;
}
return $this->max_sql_packet;
}
/**
* Read this folder's ID and cache metadata
*/
protected function _read_folder_data()
{
// already done
if (!empty($this->folder_id) || !$this->ready)
return;
$sql_arr = $this->db->fetch_assoc($this->db->query(
"SELECT `folder_id`, `synclock`, `ctag`, `changed`"
. " FROM `{$this->folders_table}` WHERE `resource` = ?",
$this->resource_uri
));
if ($sql_arr) {
$this->metadata = $sql_arr;
$this->folder_id = $sql_arr['folder_id'];
}
else {
$this->db->query("INSERT INTO `{$this->folders_table}` (`resource`, `type`)"
. " VALUES (?, ?)", $this->resource_uri, $this->folder->type);
$this->folder_id = $this->db->insert_id('kolab_folders');
$this->metadata = array();
}
}
/**
* Check lock record for this folder and wait if locked or set lock
*/
protected function _sync_lock()
{
if (!$this->ready)
return;
$this->_read_folder_data();
// abort if database is not set-up
if ($this->db->is_error()) {
$this->check_error();
$this->ready = false;
return;
}
$read_query = "SELECT `synclock`, `ctag` FROM `{$this->folders_table}` WHERE `folder_id` = ?";
$write_query = "UPDATE `{$this->folders_table}` SET `synclock` = ? WHERE `folder_id` = ? AND `synclock` = ?";
$max_lock_time = $this->_max_sync_lock_time();
// wait if locked (expire locks after 10 minutes) ...
// ... or if setting lock fails (another process meanwhile set it)
while (
(intval($this->metadata['synclock']) + $max_lock_time > time()) ||
(($res = $this->db->query($write_query, time(), $this->folder_id, intval($this->metadata['synclock']))) &&
!($affected = $this->db->affected_rows($res)))
) {
usleep(500000);
$this->metadata = $this->db->fetch_assoc($this->db->query($read_query, $this->folder_id));
}
$this->synclock = $affected > 0;
}
/**
* Remove lock for this folder
*/
public function _sync_unlock()
{
if (!$this->ready || !$this->synclock)
return;
$this->db->query(
"UPDATE `{$this->folders_table}` SET `synclock` = 0, `ctag` = ?, `changed` = ? WHERE `folder_id` = ?",
$this->metadata['ctag'],
$this->metadata['changed'],
$this->folder_id
);
$this->synclock = false;
}
protected function _max_sync_lock_time()
{
$limit = get_offset_sec(ini_get('max_execution_time'));
if ($limit <= 0 || $limit > $this->max_sync_lock_time) {
$limit = $this->max_sync_lock_time;
}
return $limit;
}
/**
* Check IMAP connection error state
*/
protected function check_error()
{
if (($err_code = $this->imap->get_error_code()) < 0) {
$this->error = kolab_storage::ERROR_IMAP_CONN;
if (($res_code = $this->imap->get_response_code()) !== 0 && in_array($res_code, array(rcube_storage::NOPERM, rcube_storage::READONLY))) {
$this->error = kolab_storage::ERROR_NO_PERMISSION;
}
}
else if ($this->db->is_error()) {
$this->error = kolab_storage::ERROR_CACHE_DB;
}
}
/**
* Resolve an object UID into an IMAP message UID
*
* @param string Kolab object UID
* @param boolean Include deleted objects
* @return int The resolved IMAP message UID
*/
public function uid2msguid($uid, $deleted = false)
{
// query local database if available
if (!isset($this->uid2msg[$uid]) && $this->ready) {
$this->_read_folder_data();
$sql_result = $this->db->query(
"SELECT `msguid` FROM `{$this->cache_table}` ".
"WHERE `folder_id` = ? AND `uid` = ? ORDER BY `msguid` DESC",
$this->folder_id,
$uid
);
if ($sql_arr = $this->db->fetch_assoc($sql_result)) {
$this->uid2msg[$uid] = $sql_arr['msguid'];
}
}
if (!isset($this->uid2msg[$uid])) {
// use IMAP SEARCH to get the right message
$index = $this->imap->search_once($this->folder->name, ($deleted ? '' : 'UNDELETED ') .
'HEADER SUBJECT ' . rcube_imap_generic::escape($uid));
$results = $index->get();
$this->uid2msg[$uid] = end($results);
}
return $this->uid2msg[$uid];
}
/**
* Getter for protected member variables
*/
public function __get($name)
{
if ($name == 'folder_id') {
$this->_read_folder_data();
}
return $this->$name;
}
/**
* Set Roundcube storage options and bypass messages/indexes cache.
*
* We use skip_deleted and threading settings specific to Kolab,
* we have to change these global settings only temporarily.
* Roundcube cache duplicates information already stored in kolab_cache,
* that's why we can disable it for better performance.
*
* @param bool $force True to start Kolab mode, False to stop it.
*/
public function imap_mode($force = false)
{
// remember current IMAP settings
if ($force) {
$this->imap_options = array(
'skip_deleted' => $this->imap->get_option('skip_deleted'),
'threading' => $this->imap->get_threading(),
);
}
// re-set IMAP settings
$this->imap->set_threading($force ? false : $this->imap_options['threading']);
$this->imap->set_options(array(
'skip_deleted' => $force ? true : $this->imap_options['skip_deleted'],
));
// if kolab cache is disabled do nothing
if (!$this->enabled) {
return;
}
static $messages_cache, $cache_bypass;
if ($messages_cache === null) {
$rcmail = rcube::get_instance();
$messages_cache = (bool) $rcmail->config->get('messages_cache');
$cache_bypass = (int) $rcmail->config->get('kolab_messages_cache_bypass');
}
if ($messages_cache) {
// handle recurrent (multilevel) bypass() calls
if ($force) {
$this->cache_bypassed += 1;
if ($this->cache_bypassed > 1) {
return;
}
}
else {
$this->cache_bypassed -= 1;
if ($this->cache_bypassed > 0) {
return;
}
}
switch ($cache_bypass) {
case 2:
// Disable messages and index cache completely
$this->imap->set_messages_caching(!$force);
break;
case 3:
case 1:
// We'll disable messages cache, but keep index cache (1) or vice-versa (3)
// Default mode is both (MODE_INDEX | MODE_MESSAGE)
$mode = $cache_bypass == 3 ? rcube_imap_cache::MODE_MESSAGE : rcube_imap_cache::MODE_INDEX;
if (!$force) {
$mode |= $cache_bypass == 3 ? rcube_imap_cache::MODE_INDEX : rcube_imap_cache::MODE_MESSAGE;
}
$this->imap->set_messages_caching(true, $mode);
}
}
}
/**
* Converts DateTime or unix timestamp into sql date format
* using server timezone.
*/
protected function _convert_datetime($datetime)
{
if (is_object($datetime)) {
$dt = clone $datetime;
$dt->setTimeZone($this->server_timezone);
return $dt->format(self::DB_DATE_FORMAT);
}
else if ($datetime) {
return date(self::DB_DATE_FORMAT, $datetime);
}
}
}
diff --git a/plugins/libkolab/lib/kolab_storage_dataset.php b/plugins/libkolab/lib/kolab_storage_dataset.php
index 9f39b12e..771e321f 100644
--- a/plugins/libkolab/lib/kolab_storage_dataset.php
+++ b/plugins/libkolab/lib/kolab_storage_dataset.php
@@ -1,153 +1,152 @@
<?php
/**
* Dataset class providing the results of a select operation on a kolab_storage_folder.
*
* Can be used as a normal array as well as an iterator in foreach() loops.
*
* @version @package_version@
* @author Thomas Bruederli <bruederli@kolabsys.com>
*
* Copyright (C) 2014, Kolab Systems AG <contact@kolabsys.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
class kolab_storage_dataset implements Iterator, ArrayAccess, Countable
{
private $cache; // kolab_storage_cache instance to use for fetching data
private $memlimit = 0;
private $buffer = false;
private $index = array();
private $data = array();
private $iteratorkey = 0;
private $error = null;
/**
* Default constructor
*
* @param object kolab_storage_cache instance to be used for fetching objects upon access
*/
public function __construct($cache)
{
$this->cache = $cache;
// enable in-memory buffering up until 1/5 of the available memory
if (function_exists('memory_get_usage')) {
$this->memlimit = parse_bytes(ini_get('memory_limit')) / 5;
$this->buffer = true;
}
}
/**
* Return error state
*/
public function is_error()
{
return !empty($this->error);
}
/**
* Set error state
*/
public function set_error($err)
{
$this->error = $err;
}
/*** Implement PHP Countable interface ***/
public function count()
{
return count($this->index);
}
/*** Implement PHP ArrayAccess interface ***/
public function offsetSet($offset, $value)
{
- $uid = $value['_msguid'];
+ $uid = !empty($value['_msguid']) ? $value['_msguid'] : $value['uid'];
if (is_null($offset)) {
$offset = count($this->index);
- $this->index[] = $uid;
- }
- else {
- $this->index[$offset] = $uid;
}
+ $this->index[$offset] = $uid;
+
// keep full payload data in memory if possible
- if ($this->memlimit && $this->buffer && isset($value['_mailbox'])) {
+ if ($this->memlimit && $this->buffer) {
$this->data[$offset] = $value;
// check memory usage and stop buffering
if ($offset % 10 == 0) {
$this->buffer = memory_get_usage() < $this->memlimit;
}
}
}
public function offsetExists($offset)
{
return isset($this->index[$offset]);
}
public function offsetUnset($offset)
{
unset($this->index[$offset]);
}
public function offsetGet($offset)
{
if (isset($this->data[$offset])) {
return $this->data[$offset];
}
- else if ($msguid = $this->index[$offset]) {
- return $this->cache->get($msguid);
+
+ if ($uid = $this->index[$offset]) {
+ return $this->cache->get($uid);
}
return null;
}
/*** Implement PHP Iterator interface ***/
public function current()
{
return $this->offsetGet($this->iteratorkey);
}
public function key()
{
return $this->iteratorkey;
}
public function next()
{
$this->iteratorkey++;
return $this->valid();
}
public function rewind()
{
$this->iteratorkey = 0;
}
public function valid()
{
return !empty($this->index[$this->iteratorkey]);
}
}
diff --git a/plugins/libkolab/lib/kolab_storage_dav.php b/plugins/libkolab/lib/kolab_storage_dav.php
new file mode 100644
index 00000000..f4480cf9
--- /dev/null
+++ b/plugins/libkolab/lib/kolab_storage_dav.php
@@ -0,0 +1,476 @@
+<?php
+
+/**
+ * Kolab storage class providing access to groupware objects on a *DAV server.
+ *
+ * @author Aleksander Machniak <machniak@apheleia-it.ch>
+ *
+ * Copyright (C) 2022, Apheleia IT AG <contact@apheleia-it.ch>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+class kolab_storage_dav
+{
+ const ERROR_DAV_CONN = 1;
+ const ERROR_CACHE_DB = 2;
+ const ERROR_NO_PERMISSION = 3;
+ const ERROR_INVALID_FOLDER = 4;
+
+ protected $dav;
+ protected $url;
+
+
+ /**
+ * Object constructor
+ */
+ public function __construct($url)
+ {
+ $this->url = $url;
+ $this->setup();
+ }
+
+ /**
+ * Setup the environment
+ */
+ public function setup()
+ {
+ $rcmail = rcube::get_instance();
+
+ $this->config = $rcmail->config;
+ $this->dav = new kolab_dav_client($this->url);
+ }
+
+ /**
+ * Get a list of storage folders for the given data type
+ *
+ * @param string Data type to list folders for (contact,distribution-list,event,task,note)
+ *
+ * @return array List of kolab_storage_dav_folder objects
+ */
+ public function get_folders($type)
+ {
+ // TODO: This should be cached
+ $folders = $this->dav->discover();
+
+ if (is_array($folders)) {
+ foreach ($folders as $idx => $folder) {
+ $folders[$idx] = new kolab_storage_dav_folder($this->dav, $folder, $type);
+ }
+ }
+
+ return $folders ?: [];
+ }
+
+ /**
+ * Getter for the storage folder for the given type
+ *
+ * @param string Data type to list folders for (contact,distribution-list,event,task,note)
+ *
+ * @return object kolab_storage_dav_folder The folder object
+ */
+ public function get_default_folder($type)
+ {
+ // TODO: Not used
+ }
+
+ /**
+ * Getter for a specific storage folder
+ *
+ * @param string Folder to access (UTF7-IMAP)
+ * @param string Expected folder type
+ *
+ * @return object kolab_storage_folder The folder object
+ */
+ public function get_folder($folder, $type = null)
+ {
+ // TODO
+ }
+
+ /**
+ * Getter for a single Kolab object, identified by its UID.
+ * This will search all folders storing objects of the given type.
+ *
+ * @param string Object UID
+ * @param string Object type (contact,event,task,journal,file,note,configuration)
+ *
+ * @return array The Kolab object represented as hash array or false if not found
+ */
+ public function get_object($uid, $type)
+ {
+ // TODO
+ return false;
+ }
+
+ /**
+ * Execute cross-folder searches with the given query.
+ *
+ * @param array Pseudo-SQL query as list of filter parameter triplets
+ * @param string Folder type (contact,event,task,journal,file,note,configuration)
+ * @param int Expected number of records or limit (for performance reasons)
+ *
+ * @return array List of Kolab data objects (each represented as hash array)
+ */
+ public function select($query, $type, $limit = null)
+ {
+ $result = [];
+
+ foreach ($this->get_folders($type) as $folder) {
+ if ($limit) {
+ $folder->set_order_and_limit(null, $limit);
+ }
+
+ foreach ($folder->select($query) as $object) {
+ $result[] = $object;
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Compose an URL to query the free/busy status for the given user
+ *
+ * @param string Email address of the user to get free/busy data for
+ * @param object DateTime Start of the query range (optional)
+ * @param object DateTime End of the query range (optional)
+ *
+ * @return string Fully qualified URL to query free/busy data
+ */
+ public static function get_freebusy_url($email, $start = null, $end = null)
+ {
+ return kolab_storage::get_freebusy_url($email, $start, $end);
+ }
+
+ /**
+ * Deletes a folder
+ *
+ * @param string $name Folder name
+ *
+ * @return bool True on success, false on failure
+ */
+ public function folder_delete($name)
+ {
+ // TODO
+ }
+
+ /**
+ * Creates a folder
+ *
+ * @param string $name Folder name (UTF7-IMAP)
+ * @param string $type Folder type
+ * @param bool $subscribed Sets folder subscription
+ * @param bool $active Sets folder state (client-side subscription)
+ *
+ * @return bool True on success, false on failure
+ */
+ public function folder_create($name, $type = null, $subscribed = false, $active = false)
+ {
+ // TODO
+ }
+
+ /**
+ * Renames DAV folder
+ *
+ * @param string $oldname Old folder name (UTF7-IMAP)
+ * @param string $newname New folder name (UTF7-IMAP)
+ *
+ * @return bool True on success, false on failure
+ */
+ public function folder_rename($oldname, $newname)
+ {
+ // TODO
+ }
+
+ /**
+ * Rename or Create a new folder.
+ *
+ * Does additional checks for permissions and folder name restrictions
+ *
+ * @param array &$prop Hash array with folder properties and metadata
+ * - name: Folder name
+ * - oldname: Old folder name when changed
+ * - parent: Parent folder to create the new one in
+ * - type: Folder type to create
+ * - subscribed: Subscribed flag (IMAP subscription)
+ * - active: Activation flag (client-side subscription)
+ *
+ * @return string|false New folder name or False on failure
+ */
+ public function folder_update(&$prop)
+ {
+ // TODO
+ }
+
+ /**
+ * Getter for human-readable name of a folder
+ *
+ * @param string $folder Folder name (UTF7-IMAP)
+ * @param string $folder_ns Will be set to namespace name of the folder
+ *
+ * @return string Name of the folder-object
+ */
+ public function object_name($folder, &$folder_ns = null)
+ {
+ // TODO: Shared folders
+ $folder_ns = 'personal';
+ return $folder;
+ }
+
+ /**
+ * Creates a SELECT field with folders list
+ *
+ * @param string $type Folder type
+ * @param array $attrs SELECT field attributes (e.g. name)
+ * @param string $current The name of current folder (to skip it)
+ *
+ * @return html_select SELECT object
+ */
+ public function folder_selector($type, $attrs, $current = '')
+ {
+ // TODO
+ }
+
+ /**
+ * Returns a list of folder names
+ *
+ * @param string Optional root folder
+ * @param string Optional name pattern
+ * @param string Data type to list folders for (contact,event,task,journal,file,note,mail,configuration)
+ * @param bool Enable to return subscribed folders only (null to use configured subscription mode)
+ * @param array Will be filled with folder-types data
+ *
+ * @return array List of folders
+ */
+ public function list_folders($root = '', $mbox = '*', $filter = null, $subscribed = null, &$folderdata = array())
+ {
+ // TODO
+ }
+
+ /**
+ * Search for shared or otherwise not listed groupware folders the user has access
+ *
+ * @param string Folder type of folders to search for
+ * @param string Search string
+ * @param array Namespace(s) to exclude results from
+ *
+ * @return array List of matching kolab_storage_folder objects
+ */
+ public function search_folders($type, $query, $exclude_ns = [])
+ {
+ // TODO
+ return [];
+ }
+
+ /**
+ * Sort the given list of folders by namespace/name
+ *
+ * @param array List of kolab_storage_dav_folder objects
+ *
+ * @return array Sorted list of folders
+ */
+ public static function sort_folders($folders)
+ {
+ // TODO
+ return $folders;
+ }
+
+ /**
+ * Returns folder types indexed by folder name
+ *
+ * @param string $prefix Folder prefix (Default '*' for all folders)
+ *
+ * @return array|bool List of folders, False on failure
+ */
+ public function folders_typedata($prefix = '*')
+ {
+ // TODO: Used by kolab_folders, kolab_activesync, kolab_delegation
+ return [];
+ }
+
+ /**
+ * Returns type of a DAV folder
+ *
+ * @param string $folder Folder name (UTF7-IMAP)
+ *
+ * @return string Folder type
+ */
+ public function folder_type($folder)
+ {
+ // TODO: Used by kolab_folders, kolab_activesync, kolab_delegation
+ return 'event';
+ }
+
+ /**
+ * Sets folder content-type.
+ *
+ * @param string $folder Folder name
+ * @param string $type Content type
+ *
+ * @return bool True on success, False otherwise
+ */
+ public function set_folder_type($folder, $type = 'mail')
+ {
+ // NOP: Used by kolab_folders, kolab_activesync, kolab_delegation
+ return false;
+ }
+
+ /**
+ * Check subscription status of this folder
+ *
+ * @param string $folder Folder name
+ * @param bool $temp Include temporary/session subscriptions
+ *
+ * @return bool True if subscribed, false if not
+ */
+ public function folder_is_subscribed($folder, $temp = false)
+ {
+ // NOP
+ return true;
+ }
+
+ /**
+ * Change subscription status of this folder
+ *
+ * @param string $folder Folder name
+ * @param bool $temp Only subscribe temporarily for the current session
+ *
+ * @return True on success, false on error
+ */
+ public function folder_subscribe($folder, $temp = false)
+ {
+ // NOP
+ return true;
+ }
+
+ /**
+ * Change subscription status of this folder
+ *
+ * @param string $folder Folder name
+ * @param bool $temp Only remove temporary subscription
+ *
+ * @return True on success, false on error
+ */
+ public function folder_unsubscribe($folder, $temp = false)
+ {
+ // NOP
+ return false;
+ }
+
+ /**
+ * Check activation status of this folder
+ *
+ * @param string $folder Folder name
+ *
+ * @return bool True if active, false if not
+ */
+ public function folder_is_active($folder)
+ {
+ // TODO
+ return true;
+ }
+
+ /**
+ * Change activation status of this folder
+ *
+ * @param string $folder Folder name
+ *
+ * @return True on success, false on error
+ */
+ public function folder_activate($folder)
+ {
+ return true;
+ }
+
+ /**
+ * Change activation status of this folder
+ *
+ * @param string $folder Folder name
+ *
+ * @return True on success, false on error
+ */
+ public function folder_deactivate($folder)
+ {
+ return false;
+ }
+
+ /**
+ * Creates default folder of specified type
+ * To be run when none of subscribed folders (of specified type) is found
+ *
+ * @param string $type Folder type
+ * @param string $props Folder properties (color, etc)
+ *
+ * @return string Folder name
+ */
+ public function create_default_folder($type, $props = [])
+ {
+ // TODO: For kolab_addressbook??
+ return '';
+ }
+
+ /**
+ * Returns a list of IMAP folders shared by the given user
+ *
+ * @param array User entry from LDAP
+ * @param string Data type to list folders for (contact,event,task,journal,file,note,mail,configuration)
+ * @param int 1 - subscribed folders only, 0 - all folders, 2 - all non-active
+ * @param array Will be filled with folder-types data
+ *
+ * @return array List of folders
+ */
+ public function list_user_folders($user, $type, $subscribed = 0, &$folderdata = [])
+ {
+ // TODO
+ return [];
+ }
+
+ /**
+ * Get a list of (virtual) top-level folders from the other users namespace
+ *
+ * @param string Data type to list folders for (contact,event,task,journal,file,note,mail,configuration)
+ * @param bool Enable to return subscribed folders only (null to use configured subscription mode)
+ *
+ * @return array List of kolab_storage_folder_user objects
+ */
+ public function get_user_folders($type, $subscribed)
+ {
+ // TODO
+ return [];
+ }
+
+ /**
+ * Handler for user_delete plugin hooks
+ *
+ * Remove all cache data from the local database related to the given user.
+ */
+ public static function delete_user_folders($args)
+ {
+ $db = rcmail::get_instance()->get_dbh();
+ $table = $db->table_name('kolab_folders', true);
+ $prefix = 'dav://' . urlencode($args['username']) . '@' . $args['host'] . '/%';
+
+ $db->query("DELETE FROM $table WHERE `resource` LIKE ?", $prefix);
+ }
+
+ /**
+ * Get folder METADATA for all supported keys
+ * Do this in one go for better caching performance
+ */
+ public function folder_metadata($folder)
+ {
+ // TODO ?
+ return [];
+ }
+}
diff --git a/plugins/libkolab/lib/kolab_storage_dav_cache.php b/plugins/libkolab/lib/kolab_storage_dav_cache.php
new file mode 100644
index 00000000..7126516f
--- /dev/null
+++ b/plugins/libkolab/lib/kolab_storage_dav_cache.php
@@ -0,0 +1,622 @@
+<?php
+
+/**
+ * Kolab storage cache class providing a local caching layer for Kolab groupware objects.
+ *
+ * @author Thomas Bruederli <bruederli@kolabsys.com>
+ * @author Aleksander Machniak <machniak@apheleia-it.ch>
+ *
+ * Copyright (C) 2012-2013, Kolab Systems AG <contact@kolabsys.com>
+ * Copyright (C) 2022, Apheleia IT AG <contact@apheleia-it.ch>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+class kolab_storage_dav_cache extends kolab_storage_cache
+{
+ /**
+ * Factory constructor
+ */
+ public static function factory(kolab_storage_folder $storage_folder)
+ {
+ $subclass = 'kolab_storage_dav_cache_' . $storage_folder->type;
+ if (class_exists($subclass)) {
+ return new $subclass($storage_folder);
+ }
+
+ rcube::raise_error(
+ ['code' => 900, 'message' => "No {$subclass} class found for folder '{$storage_folder->name}'"],
+ true
+ );
+
+ return new kolab_storage_dav_cache($storage_folder);
+ }
+
+ /**
+ * Connect cache with a storage folder
+ *
+ * @param kolab_storage_folder The storage folder instance to connect with
+ */
+ public function set_folder(kolab_storage_folder $storage_folder)
+ {
+ $this->folder = $storage_folder;
+
+ if (!$this->folder->valid) {
+ $this->ready = false;
+ return;
+ }
+
+ // compose fully qualified ressource uri for this instance
+ $this->resource_uri = $this->folder->get_resource_uri();
+ $this->cache_table = $this->db->table_name('kolab_cache_dav_' . $this->folder->type);
+ $this->ready = true;
+ }
+
+ /**
+ * Synchronize local cache data with remote
+ */
+ public function synchronize()
+ {
+ // only sync once per request cycle
+ if ($this->synched) {
+ return;
+ }
+
+ $this->sync_start = time();
+
+ // read cached folder metadata
+ $this->_read_folder_data();
+
+ $ctag = $this->folder->get_ctag();
+
+ // check cache status ($this->metadata is set in _read_folder_data())
+ if (
+ empty($this->metadata['ctag'])
+ || empty($this->metadata['changed'])
+ || $this->metadata['ctag'] !== $ctag
+ ) {
+ // lock synchronization for this folder and wait if already locked
+ $this->_sync_lock();
+
+ $result = $this->synchronize_worker();
+
+ // update ctag value (will be written to database in _sync_unlock())
+ if ($result) {
+ $this->metadata['ctag'] = $ctag;
+ $this->metadata['changed'] = date(self::DB_DATE_FORMAT, time());
+ }
+
+ // remove lock
+ $this->_sync_unlock();
+ }
+
+ $this->synched = time();
+ }
+
+ /**
+ * Perform cache synchronization
+ */
+ protected function synchronize_worker()
+ {
+ // get effective time limit we have for synchronization (~70% of the execution time)
+ $time_limit = $this->_max_sync_lock_time() * 0.7;
+
+ if (time() - $this->sync_start > $time_limit) {
+ return false;
+ }
+
+ // TODO: Implement synchronization with use of WebDAV-Sync (RFC 6578)
+
+ // Get the objects from the DAV server
+ $dav_index = $this->folder->dav->getIndex($this->folder->href, $this->folder->get_dav_type());
+
+ if (!is_array($dav_index)) {
+ rcube::raise_error([
+ 'code' => 900,
+ 'message' => "Failed to sync the kolab cache for {$this->folder->href}"
+ ], true);
+ return false;
+ }
+
+ // WARNING: For now we assume object's href is <calendar-href>/<uid>.ics,
+ // which would mean there are no duplicates (objects with the same uid).
+ // With DAV protocol we can't get UID without fetching the whole object.
+ // Also the folder_id + uid is a unique index in the database.
+ // In the future we maybe should store the href in database.
+
+ // Determine objects to fetch or delete
+ $new_index = [];
+ $update_index = [];
+ $old_index = $this->current_index(); // uid -> etag
+ $chunk_size = 20; // max numer of objects per DAV request
+
+ foreach ($dav_index as $object) {
+ $uid = $object['uid'];
+ if (isset($old_index[$uid])) {
+ $old_etag = $old_index[$uid];
+ $old_index[$uid] = null;
+
+ if ($old_etag === $object['etag']) {
+ // the object didn't change
+ continue;
+ }
+
+ $update_index[$uid] = $object['href'];
+ }
+ else {
+ $new_index[$uid] = $object['href'];
+ }
+ }
+
+ // Fetch new objects and store in DB
+ if (!empty($new_index)) {
+ foreach (array_chunk($new_index, $chunk_size, true) as $chunk) {
+ $objects = $this->folder->dav->getData($this->folder->href, $chunk);
+
+ if (!is_array($objects)) {
+ rcube::raise_error([
+ 'code' => 900,
+ 'message' => "Failed to sync the kolab cache for {$this->folder->href}"
+ ], true);
+ return false;
+ }
+
+ foreach ($objects as $object) {
+ if ($object = $this->folder->from_dav($object)) {
+ $this->_extended_insert(false, $object);
+ }
+ }
+
+ $this->_extended_insert(true, null);
+
+ // check time limit and abort sync if running too long
+ if (++$i % 25 == 0 && time() - $this->sync_start > $time_limit) {
+ return false;
+ }
+ }
+ }
+
+ // Fetch updated objects and store in DB
+ if (!empty($update_index)) {
+ foreach (array_chunk($update_index, $chunk_size, true) as $chunk) {
+ $objects = $this->folder->dav->getData($this->folder->href, $chunk);
+
+ if (!is_array($objects)) {
+ rcube::raise_error([
+ 'code' => 900,
+ 'message' => "Failed to sync the kolab cache for {$this->folder->href}"
+ ], true);
+ return false;
+ }
+
+ foreach ($objects as $object) {
+ if ($object = $this->folder->from_dav($object)) {
+ $this->save($object, $object['uid']);
+ }
+ }
+
+ // check time limit and abort sync if running too long
+ if (++$i % 25 == 0 && time() - $this->sync_start > $time_limit) {
+ return false;
+ }
+ }
+ }
+
+ // Remove deleted objects
+ $old_index = array_filter($old_index);
+ if (!empty($old_index)) {
+ $quoted_uids = join(',', array_map(array($this->db, 'quote'), $old_index));
+ $this->db->query(
+ "DELETE FROM `{$this->cache_table}` WHERE `folder_id` = ? AND `uid` IN ($quoted_uids)",
+ $this->folder_id
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Return current folder index (uid -> etag)
+ */
+ protected function current_index()
+ {
+ // read cache index
+ $sql_result = $this->db->query(
+ "SELECT `uid`, `data` FROM `{$this->cache_table}` WHERE `folder_id` = ?",
+ $this->folder_id
+ );
+
+ $index = [];
+
+ // TODO: Store etag as a separate column
+
+ while ($sql_arr = $this->db->fetch_assoc($sql_result)) {
+ if ($object = json_decode($sql_arr['data'], true)) {
+ $index[$sql_arr['uid']] = $object['etag'];
+ }
+ }
+
+ return $index;
+ }
+
+ /**
+ * Read a single entry from cache or from server directly
+ *
+ * @param string Object UID
+ * @param string Object type to read
+ */
+ public function get($uid, $type = null)
+ {
+ if ($this->ready) {
+ $this->_read_folder_data();
+
+ $sql_result = $this->db->query(
+ "SELECT * FROM `{$this->cache_table}` WHERE `folder_id` = ? AND `uid` = ?",
+ $this->folder_id,
+ $uid
+ );
+
+ if ($sql_arr = $this->db->fetch_assoc($sql_result)) {
+ $object = $this->_unserialize($sql_arr);
+ }
+ }
+
+ // fetch from DAV if not present in cache
+ if (empty($object)) {
+ if ($object = $this->folder->read_object($uid, $type ?: '*')) {
+ $this->save($object);
+ }
+ }
+
+ return $object ?: null;
+ }
+
+ /**
+ * Insert/Update a cache entry
+ *
+ * @param string Object UID
+ * @param array|false Hash array with object properties to save or false to delete the cache entry
+ */
+ public function set($uid, $object)
+ {
+ // remove old entry
+ if ($this->ready) {
+ $this->_read_folder_data();
+
+ $this->db->query(
+ "DELETE FROM `{$this->cache_table}` WHERE `folder_id` = ? AND `uid` = ?",
+ $this->folder_id,
+ $uid
+ );
+ }
+
+ if ($object) {
+ $this->save($object);
+ }
+ }
+
+ /**
+ * Insert (or update) a cache entry
+ *
+ * @param mixed Hash array with object properties to save or false to delete the cache entry
+ * @param string Optional old message UID (for update)
+ */
+ public function save($object, $olduid = null)
+ {
+ // write to cache
+ if ($this->ready) {
+ $this->_read_folder_data();
+
+ $sql_data = $this->_serialize($object);
+ $sql_data['folder_id'] = $this->folder_id;
+ $sql_data['uid'] = $object['uid'];
+
+ $args = [];
+ $cols = ['folder_id', 'uid', 'changed', 'data', 'tags', 'words'];
+ $cols = array_merge($cols, $this->extra_cols);
+
+ foreach ($cols as $idx => $col) {
+ $cols[$idx] = $this->db->quote_identifier($col);
+ $args[] = $sql_data[$col];
+ }
+
+ if ($olduid) {
+ foreach ($cols as $idx => $col) {
+ $cols[$idx] = "$col = ?";
+ }
+
+ $query = "UPDATE `{$this->cache_table}` SET " . implode(', ', $cols)
+ . " WHERE `folder_id` = ? AND `uid` = ?";
+ $args[] = $this->folder_id;
+ $args[] = $olduid;
+ }
+ else {
+ $query = "INSERT INTO `{$this->cache_table}` (`created`, " . implode(', ', $cols)
+ . ") VALUES (" . $this->db->now() . str_repeat(', ?', count($cols)) . ")";
+ }
+
+ $result = $this->db->query($query, $args);
+
+ if (!$this->db->affected_rows($result)) {
+ rcube::raise_error([
+ 'code' => 900,
+ 'message' => "Failed to write to kolab cache"
+ ], true);
+ }
+ }
+ }
+
+ /**
+ * Move an existing cache entry to a new resource
+ *
+ * @param string Entry's UID
+ * @param kolab_storage_folder Target storage folder instance
+ */
+ public function move($uid, $target)
+ {
+ // TODO
+ }
+
+ /**
+ * Update resource URI for existing folder
+ *
+ * @param string Target DAV folder to move it to
+ */
+ public function rename($new_folder)
+ {
+ // TODO
+ }
+
+ /**
+ * Select Kolab objects filtered by the given query
+ *
+ * @param array Pseudo-SQL query as list of filter parameter triplets
+ * triplet: ['<colname>', '<comparator>', '<value>']
+ * @param bool Set true to only return UIDs instead of complete objects
+ * @param bool Use fast mode to fetch only minimal set of information
+ * (no xml fetching and parsing, etc.)
+ *
+ * @return array|null|kolab_storage_dataset List of Kolab data objects (each represented as hash array) or UIDs
+ */
+ public function select($query = [], $uids = false, $fast = false)
+ {
+ $result = $uids ? [] : new kolab_storage_dataset($this);
+
+ $this->_read_folder_data();
+
+ // fetch full object data on one query if a small result set is expected
+ $fetchall = !$uids && ($this->limit ? $this->limit[0] : ($count = $this->count($query))) < self::MAX_RECORDS;
+
+ // skip SELECT if we know it will return nothing
+ if ($count === 0) {
+ return $result;
+ }
+
+ $sql_query = "SELECT " . ($fetchall ? '*' : "`uid`")
+ . " FROM `{$this->cache_table}` WHERE `folder_id` = ?"
+ . $this->_sql_where($query)
+ . (!empty($this->order_by) ? " ORDER BY " . $this->order_by : '');
+
+ $sql_result = $this->limit ?
+ $this->db->limitquery($sql_query, $this->limit[1], $this->limit[0], $this->folder_id) :
+ $this->db->query($sql_query, $this->folder_id);
+
+ if ($this->db->is_error($sql_result)) {
+ if ($uids) {
+ return null;
+ }
+
+ $result->set_error(true);
+ return $result;
+ }
+
+ while ($sql_arr = $this->db->fetch_assoc($sql_result)) {
+ if ($fast) {
+ $sql_arr['fast-mode'] = true;
+ }
+ if ($uids) {
+ $result[] = $sql_arr['uid'];
+ }
+ else if ($fetchall && ($object = $this->_unserialize($sql_arr))) {
+ $result[] = $object;
+ }
+ else if (!$fetchall) {
+ $result[] = $sql_arr;
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Get number of objects mathing the given query
+ *
+ * @param array $query Pseudo-SQL query as list of filter parameter triplets
+ *
+ * @return int The number of objects of the given type
+ */
+ public function count($query = [])
+ {
+ // read from local cache DB (assume it to be synchronized)
+ $this->_read_folder_data();
+
+ $sql_result = $this->db->query(
+ "SELECT COUNT(*) AS `numrows` FROM `{$this->cache_table}` ".
+ "WHERE `folder_id` = ?" . $this->_sql_where($query),
+ $this->folder_id
+ );
+
+ if ($this->db->is_error($sql_result)) {
+ return null;
+ }
+
+ $sql_arr = $this->db->fetch_assoc($sql_result);
+ $count = intval($sql_arr['numrows']);
+
+ return $count;
+ }
+
+ /**
+ * Getter for a single Kolab object identified by its UID
+ *
+ * @param string $uid Object UID
+ *
+ * @return array|null The Kolab object represented as hash array
+ */
+ public function get_by_uid($uid)
+ {
+ $old_limit = $this->limit;
+
+ // set limit to skip count query
+ $this->limit = [1, 0];
+
+ $list = $this->select([['uid', '=', $uid]]);
+
+ // set the limit back to defined value
+ $this->limit = $old_limit;
+
+ if (!empty($list) && !empty($list[0])) {
+ return $list[0];
+ }
+ }
+
+ /**
+ * Check DAV connection error state
+ */
+ protected function check_error()
+ {
+ // TODO ?
+ }
+
+ /**
+ * Write records into cache using extended inserts to reduce the number of queries to be executed
+ *
+ * @param bool Set to false to commit buffered insert, true to force an insert
+ * @param array Kolab object to cache
+ */
+ protected function _extended_insert($force, $object)
+ {
+ static $buffer = '';
+
+ $line = '';
+ $cols = ['folder_id', 'uid', 'created', 'changed', 'data', 'tags', 'words'];
+ if ($this->extra_cols) {
+ $cols = array_merge($cols, $this->extra_cols);
+ }
+
+ if ($object) {
+ $sql_data = $this->_serialize($object);
+
+ // Skip multi-folder insert for all databases but MySQL
+ // In Oracle we can't put long data inline, others we don't support yet
+ if (strpos($this->db->db_provider, 'mysql') !== 0) {
+ $extra_args = [];
+ $params = [$this->folder_id, $object['uid'], $sql_data['changed'],
+ $sql_data['data'], $sql_data['tags'], $sql_data['words']];
+
+ foreach ($this->extra_cols as $col) {
+ $params[] = $sql_data[$col];
+ $extra_args[] = '?';
+ }
+
+ $cols = implode(', ', array_map(function($n) { return "`{$n}`"; }, $cols));
+ $extra_args = count($extra_args) ? ', ' . implode(', ', $extra_args) : '';
+
+ $result = $this->db->query(
+ "INSERT INTO `{$this->cache_table}` ($cols)"
+ . " VALUES (?, ?, " . $this->db->now() . ", ?, ?, ?, ?$extra_args)",
+ $params
+ );
+
+ if (!$this->db->affected_rows($result)) {
+ rcube::raise_error(array(
+ 'code' => 900, 'message' => "Failed to write to kolab cache"
+ ), true);
+ }
+
+ return;
+ }
+
+ $values = array(
+ $this->db->quote($this->folder_id),
+ $this->db->quote($object['uid']),
+ $this->db->now(),
+ $this->db->quote($sql_data['changed']),
+ $this->db->quote($sql_data['data']),
+ $this->db->quote($sql_data['tags']),
+ $this->db->quote($sql_data['words']),
+ );
+ foreach ($this->extra_cols as $col) {
+ $values[] = $this->db->quote($sql_data[$col]);
+ }
+ $line = '(' . join(',', $values) . ')';
+ }
+
+ if ($buffer && ($force || (strlen($buffer) + strlen($line) > $this->max_sql_packet()))) {
+ $columns = implode(', ', array_map(function($n) { return "`{$n}`"; }, $cols));
+ $update = implode(', ', array_map(function($i) { return "`{$i}` = VALUES(`{$i}`)"; }, array_slice($cols, 2)));
+
+ $result = $this->db->query(
+ "INSERT INTO `{$this->cache_table}` ($columns) VALUES $buffer"
+ . " ON DUPLICATE KEY UPDATE $update"
+ );
+
+ if (!$this->db->affected_rows($result)) {
+ rcube::raise_error(array(
+ 'code' => 900, 'message' => "Failed to write to kolab cache"
+ ), true);
+ }
+
+ $buffer = '';
+ }
+
+ $buffer .= ($buffer ? ',' : '') . $line;
+ }
+
+ /**
+ * Helper method to turn stored cache data into a valid storage object
+ */
+ protected function _unserialize($sql_arr)
+ {
+ if ($sql_arr['fast-mode'] && !empty($sql_arr['data']) && ($object = json_decode($sql_arr['data'], true))) {
+ $object['uid'] = $sql_arr['uid'];
+
+ foreach ($this->data_props as $prop) {
+ if (isset($object[$prop]) && is_array($object[$prop]) && $object[$prop]['cl'] == 'DateTime') {
+ $object[$prop] = new DateTime($object[$prop]['dt'], new DateTimeZone($object[$prop]['tz']));
+ }
+ else if (!isset($object[$prop]) && isset($sql_arr[$prop])) {
+ $object[$prop] = $sql_arr[$prop];
+ }
+ }
+
+ if ($sql_arr['created'] && empty($object['created'])) {
+ $object['created'] = new DateTime($sql_arr['created']);
+ }
+
+ if ($sql_arr['changed'] && empty($object['changed'])) {
+ $object['changed'] = new DateTime($sql_arr['changed']);
+ }
+
+ $object['_type'] = $sql_arr['type'] ?: $this->folder->type;
+ }
+ // Fetch a complete object from the server
+ else {
+ // TODO: Fetching objects one-by-one from DAV server is slow
+ $object = $this->folder->read_object($sql_arr['uid'], '*');
+ }
+
+ return $object;
+ }
+}
diff --git a/plugins/libkolab/lib/kolab_storage_dav_cache_event.php b/plugins/libkolab/lib/kolab_storage_dav_cache_event.php
new file mode 100644
index 00000000..91a57952
--- /dev/null
+++ b/plugins/libkolab/lib/kolab_storage_dav_cache_event.php
@@ -0,0 +1,68 @@
+<?php
+
+/**
+ * Kolab storage cache class for calendar event objects
+ *
+ * @author Thomas Bruederli <bruederli@kolabsys.com>
+ *
+ * Copyright (C) 2013, Kolab Systems AG <contact@kolabsys.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+class kolab_storage_dav_cache_event extends kolab_storage_dav_cache
+{
+ protected $extra_cols = array('dtstart','dtend');
+ protected $data_props = array('categories', 'status', 'attendees', 'etag');
+
+ /**
+ * Helper method to convert the given Kolab object into a dataset to be written to cache
+ *
+ * @override
+ */
+ protected function _serialize($object)
+ {
+ $sql_data = parent::_serialize($object);
+
+ $sql_data['dtstart'] = $this->_convert_datetime($object['start']);
+ $sql_data['dtend'] = $this->_convert_datetime($object['end']);
+
+ // extend date range for recurring events
+ if (!empty($object['recurrence']) && !empty($object['_formatobj'])) {
+ $recurrence = new kolab_date_recurrence($object['_formatobj']);
+ $dtend = $recurrence->end() ?: new DateTime('now +100 years');
+ $sql_data['dtend'] = $this->_convert_datetime($dtend);
+ }
+
+ // extend start/end dates to spawn all exceptions
+ if (is_array($object['exceptions'])) {
+ foreach ($object['exceptions'] as $exception) {
+ if (is_a($exception['start'], 'DateTime')) {
+ $exstart = $this->_convert_datetime($exception['start']);
+ if ($exstart < $sql_data['dtstart']) {
+ $sql_data['dtstart'] = $exstart;
+ }
+ }
+ if (is_a($exception['end'], 'DateTime')) {
+ $exend = $this->_convert_datetime($exception['end']);
+ if ($exend > $sql_data['dtend']) {
+ $sql_data['dtend'] = $exend;
+ }
+ }
+ }
+ }
+
+ return $sql_data;
+ }
+}
diff --git a/plugins/libkolab/lib/kolab_storage_dav_folder.php b/plugins/libkolab/lib/kolab_storage_dav_folder.php
new file mode 100644
index 00000000..7bee428a
--- /dev/null
+++ b/plugins/libkolab/lib/kolab_storage_dav_folder.php
@@ -0,0 +1,529 @@
+<?php
+
+/**
+ * A class representing a DAV folder object.
+ *
+ * @author Aleksander Machniak <machniak@apheleia-it.ch>
+ *
+ * Copyright (C) 2014-2022, Apheleia IT AG <contact@apheleia-it.ch>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+class kolab_storage_dav_folder extends kolab_storage_folder
+{
+ public $dav;
+ public $href;
+ public $attributes;
+
+ /**
+ * Object constructor
+ */
+ public function __construct($dav, $attributes, $type_annotation = '')
+ {
+ $this->attributes = $attributes;
+ $this->href = $this->attributes['href'];
+
+ // Here we assume the last element of the folder path is the folder ID
+ // if that's not the case, we should consider generating an ID
+ $href = explode('/', unslashify($this->href));
+ $this->id = $href[count($href) - 1];
+ $this->dav = $dav;
+ $this->valid = true;
+
+ list($this->type, $suffix) = explode('.', $type_annotation);
+ $this->default = $suffix == 'default';
+ $this->subtype = $this->default ? '' : $suffix;
+
+ // Init cache
+ $this->cache = kolab_storage_dav_cache::factory($this);
+ }
+
+ /**
+ * Returns the owner of the folder.
+ *
+ * @param bool Return a fully qualified owner name (i.e. including domain for shared folders)
+ *
+ * @return string The owner of this folder.
+ */
+ public function get_owner($fully_qualified = false)
+ {
+ // return cached value
+ if (isset($this->owner)) {
+ return $this->owner;
+ }
+
+ $rcube = rcube::get_instance();
+ $this->owner = $rcube->get_user_name();
+ $this->valid = true;
+
+ // TODO: Support shared folders
+
+ return $this->owner;
+ }
+
+ /**
+ * Get a folder Etag identifier
+ */
+ public function get_ctag()
+ {
+ return $this->attributes['ctag'];
+ }
+
+ /**
+ * Getter for the name of the namespace to which the folder belongs
+ *
+ * @return string Name of the namespace (personal, other, shared)
+ */
+ public function get_namespace()
+ {
+ // TODO: Support shared folders
+ return 'personal';
+ }
+
+ /**
+ * Get the display name value of this folder
+ *
+ * @return string Folder name
+ */
+ public function get_name()
+ {
+ return kolab_storage_dav::object_name($this->attributes['name']);
+ }
+
+ /**
+ * Getter for the top-end folder name (not the entire path)
+ *
+ * @return string Name of this folder
+ */
+ public function get_foldername()
+ {
+ return $this->attributes['name'];
+ }
+
+ /**
+ * Getter for parent folder path
+ *
+ * @return string Full path to parent folder
+ */
+ public function get_parent()
+ {
+ // TODO
+ return '';
+ }
+
+ /**
+ * Compose a unique resource URI for this folder
+ */
+ public function get_resource_uri()
+ {
+ if (!empty($this->resource_uri)) {
+ return $this->resource_uri;
+ }
+
+ // compose fully qualified ressource uri for this instance
+ $host = preg_replace('|^https?://|', 'dav://' . urlencode($this->get_owner(true)) . '@', $this->dav->url);
+ $path = $this->href[0] == '/' ? $this->href : "/{$this->href}";
+
+ $this->resource_uri = unslashify($host) . $path;
+
+ return $this->resource_uri;
+ }
+
+ /**
+ * Getter for the Cyrus mailbox identifier corresponding to this folder
+ * (e.g. user/john.doe/Calendar/Personal@example.org)
+ *
+ * @return string Mailbox ID
+ */
+ public function get_mailbox_id()
+ {
+ // TODO: This is used with Bonnie related features
+ return '';
+ }
+
+ /**
+ * Get the color value stored in metadata
+ *
+ * @param string Default color value to return if not set
+ *
+ * @return mixed Color value from the folder metadata or $default if not set
+ */
+ public function get_color($default = null)
+ {
+ return !empty($this->attributes['color']) ? $this->attributes['color'] : $default;
+ }
+
+ /**
+ * Get ACL information for this folder
+ *
+ * @return string Permissions as string
+ */
+ public function get_myrights()
+ {
+ // TODO
+ return '';
+ }
+
+ /**
+ * Helper method to extract folder UID
+ *
+ * @return string Folder's UID
+ */
+ public function get_uid()
+ {
+ // TODO ???
+ return '';
+ }
+
+ /**
+ * Check activation status of this folder
+ *
+ * @return bool True if enabled, false if not
+ */
+ public function is_active()
+ {
+ // TODO
+ return true;
+ }
+
+ /**
+ * Change activation status of this folder
+ *
+ * @param bool The desired subscription status: true = active, false = not active
+ *
+ * @return bool True on success, false on error
+ */
+ public function activate($active)
+ {
+ // TODO
+ return true;
+ }
+
+ /**
+ * Check subscription status of this folder
+ *
+ * @return bool True if subscribed, false if not
+ */
+ public function is_subscribed()
+ {
+ // TODO
+ return true;
+ }
+
+ /**
+ * Change subscription status of this folder
+ *
+ * @param bool The desired subscription status: true = subscribed, false = not subscribed
+ *
+ * @return True on success, false on error
+ */
+ public function subscribe($subscribed)
+ {
+ // TODO
+ return true;
+ }
+
+ /**
+ * Delete the specified object from this folder.
+ *
+ * @param array|string $object The Kolab object to delete or object UID
+ * @param bool $expunge Should the folder be expunged?
+ *
+ * @return bool True if successful, false on error
+ */
+ public function delete($object, $expunge = true)
+ {
+ if (!$this->valid) {
+ return false;
+ }
+
+ $uid = is_array($object) ? $object['uid'] : $object;
+
+ $success = $this->dav->delete($this->object_location($uid), $content);
+
+ if ($success) {
+ $this->cache->set($uid, false);
+ }
+
+ return $success;
+ }
+
+ /**
+ *
+ */
+ public function delete_all()
+ {
+ if (!$this->valid) {
+ return false;
+ }
+
+ // TODO: This method is used by kolab_addressbook plugin only
+
+ $this->cache->purge();
+
+ return false;
+ }
+
+ /**
+ * Restore a previously deleted object
+ *
+ * @param string $uid Object UID
+ *
+ * @return mixed Message UID on success, false on error
+ */
+ public function undelete($uid)
+ {
+ if (!$this->valid) {
+ return false;
+ }
+
+ // TODO
+
+ return false;
+ }
+
+ /**
+ * Move a Kolab object message to another IMAP folder
+ *
+ * @param string Object UID
+ * @param string IMAP folder to move object to
+ *
+ * @return bool True on success, false on failure
+ */
+ public function move($uid, $target_folder)
+ {
+ if (!$this->valid) {
+ return false;
+ }
+
+ // TODO
+
+ return false;
+ }
+
+ /**
+ * Save an object in this folder.
+ *
+ * @param array $object The array that holds the data of the object.
+ * @param string $type The type of the kolab object.
+ * @param string $uid The UID of the old object if it existed before
+ *
+ * @return mixed False on error or object UID on success
+ */
+ public function save(&$object, $type = null, $uid = null)
+ {
+ if (!$this->valid || empty($object)) {
+ return false;
+ }
+
+ if (!$type) {
+ $type = $this->type;
+ }
+/*
+ // copy attachments from old message
+ $copyfrom = $object['_copyfrom'] ?: $object['_msguid'];
+ if (!empty($copyfrom) && ($old = $this->cache->get($copyfrom, $type, $object['_mailbox']))) {
+ foreach ((array)$old['_attachments'] as $key => $att) {
+ if (!isset($object['_attachments'][$key])) {
+ $object['_attachments'][$key] = $old['_attachments'][$key];
+ }
+ // unset deleted attachment entries
+ if ($object['_attachments'][$key] == false) {
+ unset($object['_attachments'][$key]);
+ }
+ // load photo.attachment from old Kolab2 format to be directly embedded in xcard block
+ else if ($type == 'contact' && ($key == 'photo.attachment' || $key == 'kolab-picture.png') && $att['id']) {
+ if (!isset($object['photo']))
+ $object['photo'] = $this->get_attachment($copyfrom, $att['id'], $object['_mailbox']);
+ unset($object['_attachments'][$key]);
+ }
+ }
+ }
+
+ // process attachments
+ if (is_array($object['_attachments'])) {
+ $numatt = count($object['_attachments']);
+ foreach ($object['_attachments'] as $key => $attachment) {
+ // FIXME: kolab_storage and Roundcube attachment hooks use different fields!
+ if (empty($attachment['content']) && !empty($attachment['data'])) {
+ $attachment['content'] = $attachment['data'];
+ unset($attachment['data'], $object['_attachments'][$key]['data']);
+ }
+
+ // make sure size is set, so object saved in cache contains this info
+ if (!isset($attachment['size'])) {
+ if (!empty($attachment['content'])) {
+ if (is_resource($attachment['content'])) {
+ // this need to be a seekable resource, otherwise
+ // fstat() failes and we're unable to determine size
+ // here nor in rcube_imap_generic before IMAP APPEND
+ $stat = fstat($attachment['content']);
+ $attachment['size'] = $stat ? $stat['size'] : 0;
+ }
+ else {
+ $attachment['size'] = strlen($attachment['content']);
+ }
+ }
+ else if (!empty($attachment['path'])) {
+ $attachment['size'] = filesize($attachment['path']);
+ }
+ $object['_attachments'][$key] = $attachment;
+ }
+
+ // generate unique keys (used as content-id) for attachments
+ if (is_numeric($key) && $key < $numatt) {
+ // derrive content-id from attachment file name
+ $ext = preg_match('/(\.[a-z0-9]{1,6})$/i', $attachment['name'], $m) ? $m[1] : null;
+ $basename = preg_replace('/[^a-z0-9_.-]/i', '', basename($attachment['name'], $ext)); // to 7bit ascii
+ if (!$basename) $basename = 'noname';
+ $cid = $basename . '.' . microtime(true) . $key . $ext;
+
+ $object['_attachments'][$cid] = $attachment;
+ unset($object['_attachments'][$key]);
+ }
+ }
+ }
+*/
+ $rcmail = rcube::get_instance();
+ $result = false;
+
+ // generate and save object message
+ if ($content = $this->to_dav($object)) {
+ $result = $this->dav->create($this->object_location($object['uid']), $content);
+
+ if ($result !== false) {
+ // insert/update object in the cache
+ $object['etag'] = $result;
+ $this->cache->save($object, $uid);
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Fetch the object the DAV server and convert to internal format
+ *
+ * @param string The object UID to fetch
+ * @param string The object type expected (use wildcard '*' to accept all types)
+ *
+ * @return mixed Hash array representing the Kolab object, a kolab_format instance or false if not found
+ */
+ public function read_object($uid, $type = null)
+ {
+ if (!$this->valid) {
+ return false;
+ }
+
+ $href = $this->object_location($uid);
+ $objects = $this->dav->getData($this->href, [$href]);
+
+ if (!is_array($objects) || count($objects) != 1) {
+ rcube::raise_error([
+ 'code' => 900,
+ 'message' => "Failed to fetch {$href}"
+ ], true);
+ return false;
+ }
+
+ return $this->from_dav($objects[0]);
+ }
+
+ /**
+ * Convert DAV object into PHP array
+ *
+ * @param array Object data in kolab_dav_client::fetchData() format
+ *
+ * @return array Object properties
+ */
+ public function from_dav($object)
+ {
+ if ($this->type == 'event') {
+ $ical = libcalendaring::get_ical();
+ $events = $ical->import($object['data']);
+
+ if (!count($events) || empty($events[0]['uid'])) {
+ return false;
+ }
+
+ $result = $events[0];
+ }
+
+ $result['etag'] = $object['etag'];
+ $result['href'] = $object['href'];
+ $result['uid'] = $object['uid'] ?: $result['uid'];
+
+ return $result;
+ }
+
+ /**
+ * Convert Kolab object into DAV format (iCalendar)
+ */
+ public function to_dav($object)
+ {
+ $result = '';
+
+ if ($this->type == 'event') {
+ $ical = libcalendaring::get_ical();
+ // TODO: Attachments?
+ $result = $ical->export([$object]);
+ }
+
+ return $result;
+ }
+
+ protected function object_location($uid)
+ {
+ return unslashify($this->href) . '/' . urlencode($uid) . '.' . $this->get_dav_ext();
+ }
+
+ /**
+ * Get a folder DAV content type
+ */
+ public function get_dav_type()
+ {
+ $types = [
+ 'event' => 'VEVENT',
+ 'task' => 'VTODO',
+ 'contact' => 'VCARD',
+ ];
+
+ return $types[$this->type];
+ }
+
+
+ /**
+ * Get a DAV file extension for specified Kolab type
+ */
+ public function get_dav_ext()
+ {
+ $types = [
+ 'event' => 'ics',
+ 'task' => 'ics',
+ 'contact' => 'vcf',
+ ];
+
+ return $types[$this->type];
+ }
+
+ /**
+ * Return folder name as string representation of this object
+ *
+ * @return string Full IMAP folder name
+ */
+ public function __toString()
+ {
+ return $this->attributes['name'];
+ }
+}
diff --git a/plugins/libkolab/lib/kolab_storage_folder.php b/plugins/libkolab/lib/kolab_storage_folder.php
index 0cc6fbad..194289de 100644
--- a/plugins/libkolab/lib/kolab_storage_folder.php
+++ b/plugins/libkolab/lib/kolab_storage_folder.php
@@ -1,1167 +1,1168 @@
<?php
/**
* The kolab_storage_folder class represents an IMAP folder on the Kolab server.
*
* @version @package_version@
* @author Thomas Bruederli <bruederli@kolabsys.com>
* @author Aleksander Machniak <machniak@kolabsys.com>
*
* Copyright (C) 2012-2013, Kolab Systems AG <contact@kolabsys.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
class kolab_storage_folder extends kolab_storage_folder_api
{
/**
* The kolab_storage_cache instance for caching operations
* @var object
*/
public $cache;
/**
* Indicate validity status
* @var boolean
*/
public $valid = false;
protected $error = 0;
protected $resource_uri;
/**
* Default constructor
*
* @param string The folder name/path
* @param string Expected folder type
+ * @param string Optional folder type if known
*/
function __construct($name, $type = null, $type_annotation = null)
{
parent::__construct($name);
$this->set_folder($name, $type, $type_annotation);
}
/**
* Set the IMAP folder this instance connects to
*
* @param string The folder name/path
* @param string Expected folder type
* @param string Optional folder type if known
*/
public function set_folder($name, $type = null, $type_annotation = null)
{
$this->name = $name;
if (empty($type_annotation)) {
$type_annotation = $this->get_type();
}
$oldtype = $this->type;
list($this->type, $suffix) = explode('.', $type_annotation);
$this->default = $suffix == 'default';
$this->subtype = $this->default ? '' : $suffix;
$this->id = kolab_storage::folder_id($name);
$this->valid = !empty($this->type) && $this->type != 'mail' && (!$type || $this->type == $type);
if (!$this->valid) {
$this->error = $this->imap->get_error_code() < 0 ? kolab_storage::ERROR_IMAP_CONN : kolab_storage::ERROR_INVALID_FOLDER;
}
// reset cached object properties
$this->owner = $this->namespace = $this->resource_uri = $this->info = $this->idata = null;
// get a new cache instance if folder type changed
if (!$this->cache || $this->type != $oldtype)
$this->cache = kolab_storage_cache::factory($this);
else
$this->cache->set_folder($this);
$this->imap->set_folder($this->name);
}
/**
* Returns code of last error
*
* @return int Error code
*/
public function get_error()
{
return $this->error ?: $this->cache->get_error();
}
/**
* Check IMAP connection error state
*/
public function check_error()
{
if (($err_code = $this->imap->get_error_code()) < 0) {
$this->error = kolab_storage::ERROR_IMAP_CONN;
if (($res_code = $this->imap->get_response_code()) !== 0 && in_array($res_code, array(rcube_storage::NOPERM, rcube_storage::READONLY))) {
$this->error = kolab_storage::ERROR_NO_PERMISSION;
}
}
return $this->error;
}
/**
* Compose a unique resource URI for this IMAP folder
*/
public function get_resource_uri()
{
if (!empty($this->resource_uri)) {
return $this->resource_uri;
}
// strip namespace prefix from folder name
$ns = $this->get_namespace();
$nsdata = $this->imap->get_namespace($ns);
if (is_array($nsdata[0]) && strlen($nsdata[0][0]) && strpos($this->name, $nsdata[0][0]) === 0) {
$subpath = substr($this->name, strlen($nsdata[0][0]));
if ($ns == 'other') {
list($user, $suffix) = explode($nsdata[0][1], $subpath, 2);
$subpath = $suffix;
}
}
else {
$subpath = $this->name;
}
// compose fully qualified ressource uri for this instance
$this->resource_uri = 'imap://' . urlencode($this->get_owner(true)) . '@' . $this->imap->options['host'] . '/' . $subpath;
return $this->resource_uri;
}
/**
* Helper method to extract folder UID metadata
*
* @return string Folder's UID
*/
public function get_uid()
{
// UID is defined in folder METADATA
$metakeys = array(kolab_storage::UID_KEY_SHARED, kolab_storage::UID_KEY_CYRUS);
$metadata = $this->get_metadata();
if ($metadata !== null) {
foreach ($metakeys as $key) {
if ($uid = $metadata[$key]) {
return $uid;
}
}
// generate a folder UID and set it to IMAP
$uid = rtrim(chunk_split(md5($this->name . $this->get_owner() . uniqid('-', true)), 12, '-'), '-');
if ($this->set_uid($uid)) {
return $uid;
}
}
$this->check_error();
// create hash from folder name if we can't write the UID metadata
return md5($this->name . $this->get_owner());
}
/**
* Helper method to set an UID value to the given IMAP folder instance
*
* @param string Folder's UID
* @return boolean True on succes, False on failure
*/
public function set_uid($uid)
{
$success = $this->set_metadata(array(kolab_storage::UID_KEY_SHARED => $uid));
$this->check_error();
return $success;
}
/**
* Compose a folder Etag identifier
*/
public function get_ctag()
{
$fdata = $this->get_imap_data();
$this->check_error();
return sprintf('%d-%d-%d', $fdata['UIDVALIDITY'], $fdata['HIGHESTMODSEQ'], $fdata['UIDNEXT']);
}
/**
* Check activation status of this folder
*
* @return boolean True if enabled, false if not
*/
public function is_active()
{
return kolab_storage::folder_is_active($this->name);
}
/**
* Change activation status of this folder
*
* @param boolean The desired subscription status: true = active, false = not active
*
* @return True on success, false on error
*/
public function activate($active)
{
return $active ? kolab_storage::folder_activate($this->name) : kolab_storage::folder_deactivate($this->name);
}
/**
* Check subscription status of this folder
*
* @return boolean True if subscribed, false if not
*/
public function is_subscribed()
{
return kolab_storage::folder_is_subscribed($this->name);
}
/**
* Change subscription status of this folder
*
* @param boolean The desired subscription status: true = subscribed, false = not subscribed
*
* @return True on success, false on error
*/
public function subscribe($subscribed)
{
return $subscribed ? kolab_storage::folder_subscribe($this->name) : kolab_storage::folder_unsubscribe($this->name);
}
/**
* Get number of objects stored in this folder
*
* @param mixed Pseudo-SQL query as list of filter parameter triplets
* or string with object type (e.g. contact, event, todo, journal, note, configuration)
*
* @return integer The number of objects of the given type
* @see self::select()
*/
public function count($query = null)
{
if (!$this->valid) {
return 0;
}
// synchronize cache first
$this->cache->synchronize();
return $this->cache->count($this->_prepare_query($query));
}
/**
* List Kolab objects matching the given query
*
* @param mixed Pseudo-SQL query as list of filter parameter triplets
* or string with object type (e.g. contact, event, todo, journal, note, configuration)
*
* @return array List of Kolab data objects (each represented as hash array)
* @deprecated Use select()
*/
public function get_objects($query = array())
{
return $this->select($query);
}
/**
* Select Kolab objects matching the given query
*
* @param mixed Pseudo-SQL query as list of filter parameter triplets
* or string with object type (e.g. contact, event, todo, journal, note, configuration)
* @param boolean Use fast mode to fetch only minimal set of information
* (no xml fetching and parsing, etc.)
*
* @return array List of Kolab data objects (each represented as hash array)
*/
public function select($query = array(), $fast = false)
{
if (!$this->valid) {
return array();
}
// synchronize caches
$this->cache->synchronize();
// fetch objects from cache
return $this->cache->select($this->_prepare_query($query), false, $fast);
}
/**
* Getter for object UIDs only
*
* @param array Pseudo-SQL query as list of filter parameter triplets
* @return array List of Kolab object UIDs
*/
public function get_uids($query = array())
{
if (!$this->valid) {
return array();
}
// synchronize caches
$this->cache->synchronize();
// fetch UIDs from cache
return $this->cache->select($this->_prepare_query($query), true);
}
/**
* Setter for ORDER BY and LIMIT parameters for cache queries
*
* @param array List of columns to order by
* @param integer Limit result set to this length
* @param integer Offset row
*/
public function set_order_and_limit($sortcols, $length = null, $offset = 0)
{
$this->cache->set_order_by($sortcols);
if ($length !== null) {
$this->cache->set_limit($length, $offset);
}
}
/**
* Helper method to sanitize query arguments
*/
private function _prepare_query($query)
{
// string equals type query
// FIXME: should not be called this way!
if (is_string($query)) {
return $this->cache->has_type_col() && !empty($query) ? array(array('type','=',$query)) : array();
}
foreach ((array)$query as $i => $param) {
if ($param[0] == 'type' && !$this->cache->has_type_col()) {
unset($query[$i]);
}
else if (($param[0] == 'dtstart' || $param[0] == 'dtend' || $param[0] == 'changed')) {
if (is_object($param[2]) && is_a($param[2], 'DateTime'))
$param[2] = $param[2]->format('U');
if (is_numeric($param[2]))
$query[$i][2] = date('Y-m-d H:i:s', $param[2]);
}
}
return $query;
}
/**
* Getter for a single Kolab object identified by its UID
*
* @param string $uid Object UID
*
* @return array The Kolab object represented as hash array
*/
public function get_object($uid)
{
if (!$this->valid || !$uid) {
return false;
}
// synchronize caches
$this->cache->synchronize();
return $this->cache->get_by_uid($uid);
}
/**
* Fetch a Kolab object attachment which is stored in a separate part
* of the mail MIME message that represents the Kolab record.
*
* @param string Object's UID
* @param string The attachment's mime number
* @param string IMAP folder where message is stored;
* If set, that also implies that the given UID is an IMAP UID
* @param bool True to print the part content
* @param resource File pointer to save the message part
* @param boolean Disables charset conversion
*
* @return mixed The attachment content as binary string
*/
public function get_attachment($uid, $part, $mailbox = null, $print = false, $fp = null, $skip_charset_conv = false)
{
if ($this->valid && ($msguid = ($mailbox ? $uid : $this->cache->uid2msguid($uid)))) {
$this->imap->set_folder($mailbox ? $mailbox : $this->name);
if (substr($part, 0, 2) == 'i:') {
// attachment data is stored in XML
if ($object = $this->cache->get($msguid)) {
// load data from XML (attachment content is not stored in cache)
if ($object['_formatobj'] && isset($object['_size'])) {
$object['_attachments'] = array();
$object['_formatobj']->get_attachments($object);
}
foreach ($object['_attachments'] as $attach) {
if ($attach['id'] == $part) {
if ($print) echo $attach['content'];
else if ($fp) fwrite($fp, $attach['content']);
else return $attach['content'];
return true;
}
}
}
}
else {
// return message part from IMAP directly
// TODO: We could improve performance if we cache part's encoding
// without 3rd argument get_message_part() will request BODYSTRUCTURE from IMAP
return $this->imap->get_message_part($msguid, $part, null, $print, $fp, $skip_charset_conv);
}
}
return null;
}
/**
* Fetch the mime message from the storage server and extract
* the Kolab groupware object from it
*
* @param string The IMAP message UID to fetch
* @param string The object type expected (use wildcard '*' to accept all types)
* @param string The folder name where the message is stored
*
* @return mixed Hash array representing the Kolab object, a kolab_format instance or false if not found
*/
public function read_object($msguid, $type = null, $folder = null)
{
if (!$this->valid) {
return false;
}
if (!$type) $type = $this->type;
if (!$folder) $folder = $this->name;
$this->imap->set_folder($folder);
$this->cache->imap_mode(true);
$message = new rcube_message($msguid);
$this->cache->imap_mode(false);
// Message doesn't exist?
if (empty($message->headers)) {
return false;
}
// extract the X-Kolab-Type header from the XML attachment part if missing
if (empty($message->headers->others['x-kolab-type'])) {
foreach ((array)$message->attachments as $part) {
if (strpos($part->mimetype, kolab_format::KTYPE_PREFIX) === 0) {
$message->headers->others['x-kolab-type'] = $part->mimetype;
break;
}
}
}
// fix buggy messages stating the X-Kolab-Type header twice
else if (is_array($message->headers->others['x-kolab-type'])) {
$message->headers->others['x-kolab-type'] = reset($message->headers->others['x-kolab-type']);
}
// no object type header found: abort
if (empty($message->headers->others['x-kolab-type'])) {
rcube::raise_error(array(
'code' => 600,
'type' => 'php',
'file' => __FILE__,
'line' => __LINE__,
'message' => "No X-Kolab-Type information found in message $msguid ($this->name).",
), true);
return false;
}
$object_type = kolab_format::mime2object_type($message->headers->others['x-kolab-type']);
$content_type = kolab_format::KTYPE_PREFIX . $object_type;
// check object type header and abort on mismatch
if ($type != '*' && strpos($object_type, $type) !== 0 && !($object_type == 'distribution-list' && $type == 'contact')) {
return false;
}
$attachments = array();
// get XML part
foreach ((array)$message->attachments as $part) {
if (!$xml && ($part->mimetype == $content_type || preg_match('!application/([a-z.]+\+)?xml!i', $part->mimetype))) {
$xml = $message->get_part_body($part->mime_id, true);
}
else if ($part->filename || $part->content_id) {
$key = $part->content_id ? trim($part->content_id, '<>') : $part->filename;
$size = null;
// Use Content-Disposition 'size' as for the Kolab Format spec.
if (isset($part->d_parameters['size'])) {
$size = $part->d_parameters['size'];
}
// we can trust part size only if it's not encoded
else if ($part->encoding == 'binary' || $part->encoding == '7bit' || $part->encoding == '8bit') {
$size = $part->size;
}
$attachments[$key] = array(
'id' => $part->mime_id,
'name' => $part->filename,
'mimetype' => $part->mimetype,
'size' => $size,
);
}
}
if (!$xml) {
rcube::raise_error(array(
'code' => 600,
'type' => 'php',
'file' => __FILE__,
'line' => __LINE__,
'message' => "Could not find Kolab data part in message $msguid ($this->name).",
), true);
return false;
}
// check kolab format version
$format_version = $message->headers->others['x-kolab-mime-version'];
if (empty($format_version)) {
list($xmltype, $subtype) = explode('.', $object_type);
$xmlhead = substr($xml, 0, 512);
// detect old Kolab 2.0 format
if (strpos($xmlhead, '<' . $xmltype) !== false && strpos($xmlhead, 'xmlns=') === false)
$format_version = '2.0';
else
$format_version = '3.0'; // assume 3.0
}
// get Kolab format handler for the given type
$format = kolab_format::factory($object_type, $format_version);
if (is_a($format, 'PEAR_Error'))
return false;
// load Kolab object from XML part
$format->load($xml);
if ($format->is_valid()) {
$object = $format->to_array(array('_attachments' => $attachments));
$object['_type'] = $object_type;
$object['_msguid'] = $msguid;
$object['_mailbox'] = $this->name;
$object['_formatobj'] = $format;
$object['_size'] = strlen($xml);
return $object;
}
else {
// try to extract object UID from XML block
if (preg_match('!<uid>(.+)</uid>!Uims', $xml, $m))
$msgadd = " UID = " . trim(strip_tags($m[1]));
rcube::raise_error(array(
'code' => 600,
'type' => 'php',
'file' => __FILE__,
'line' => __LINE__,
'message' => "Could not parse Kolab object data in message $msguid ($this->name)." . $msgadd,
), true);
self::save_user_xml("$msguid.xml", $xml);
}
return false;
}
/**
* Save an object in this folder.
*
* @param array $object The array that holds the data of the object.
* @param string $type The type of the kolab object.
* @param string $uid The UID of the old object if it existed before
*
* @return mixed False on error or IMAP message UID on success
*/
public function save(&$object, $type = null, $uid = null)
{
- if (!$this->valid && empty($object)) {
+ if (!$this->valid || empty($object)) {
return false;
}
if (!$type)
$type = $this->type;
// copy attachments from old message
$copyfrom = $object['_copyfrom'] ?: $object['_msguid'];
if (!empty($copyfrom) && ($old = $this->cache->get($copyfrom, $type, $object['_mailbox']))) {
foreach ((array)$old['_attachments'] as $key => $att) {
if (!isset($object['_attachments'][$key])) {
$object['_attachments'][$key] = $old['_attachments'][$key];
}
// unset deleted attachment entries
if ($object['_attachments'][$key] == false) {
unset($object['_attachments'][$key]);
}
// load photo.attachment from old Kolab2 format to be directly embedded in xcard block
else if ($type == 'contact' && ($key == 'photo.attachment' || $key == 'kolab-picture.png') && $att['id']) {
if (!isset($object['photo']))
$object['photo'] = $this->get_attachment($copyfrom, $att['id'], $object['_mailbox']);
unset($object['_attachments'][$key]);
}
}
}
// save contact photo to attachment for Kolab2 format
if (kolab_storage::$version == '2.0' && $object['photo']) {
$attkey = 'kolab-picture.png'; // this file name is hard-coded in libkolab/kolabformatV2/contact.cpp
$object['_attachments'][$attkey] = array(
'mimetype'=> rcube_mime::image_content_type($object['photo']),
'content' => preg_match('![^a-z0-9/=+-]!i', $object['photo']) ? $object['photo'] : base64_decode($object['photo']),
);
}
// process attachments
if (is_array($object['_attachments'])) {
$numatt = count($object['_attachments']);
foreach ($object['_attachments'] as $key => $attachment) {
// FIXME: kolab_storage and Roundcube attachment hooks use different fields!
if (empty($attachment['content']) && !empty($attachment['data'])) {
$attachment['content'] = $attachment['data'];
unset($attachment['data'], $object['_attachments'][$key]['data']);
}
// make sure size is set, so object saved in cache contains this info
if (!isset($attachment['size'])) {
if (!empty($attachment['content'])) {
if (is_resource($attachment['content'])) {
// this need to be a seekable resource, otherwise
// fstat() failes and we're unable to determine size
// here nor in rcube_imap_generic before IMAP APPEND
$stat = fstat($attachment['content']);
$attachment['size'] = $stat ? $stat['size'] : 0;
}
else {
$attachment['size'] = strlen($attachment['content']);
}
}
else if (!empty($attachment['path'])) {
$attachment['size'] = filesize($attachment['path']);
}
$object['_attachments'][$key] = $attachment;
}
// generate unique keys (used as content-id) for attachments
if (is_numeric($key) && $key < $numatt) {
// derrive content-id from attachment file name
$ext = preg_match('/(\.[a-z0-9]{1,6})$/i', $attachment['name'], $m) ? $m[1] : null;
$basename = preg_replace('/[^a-z0-9_.-]/i', '', basename($attachment['name'], $ext)); // to 7bit ascii
if (!$basename) $basename = 'noname';
$cid = $basename . '.' . microtime(true) . $key . $ext;
$object['_attachments'][$cid] = $attachment;
unset($object['_attachments'][$key]);
}
}
}
// save recurrence exceptions as individual objects due to lack of support in Kolab v2 format
if (kolab_storage::$version == '2.0' && $object['recurrence']['EXCEPTIONS']) {
$this->save_recurrence_exceptions($object, $type);
}
// check IMAP BINARY extension support for 'file' objects
// allow configuration to workaround bug in Cyrus < 2.4.17
$rcmail = rcube::get_instance();
$binary = $type == 'file' && !$rcmail->config->get('kolab_binary_disable') && $this->imap->get_capability('BINARY');
// generate and save object message
if ($raw_msg = $this->build_message($object, $type, $binary, $body_file)) {
// resolve old msguid before saving
if ($uid && empty($object['_msguid']) && ($msguid = $this->cache->uid2msguid($uid))) {
$object['_msguid'] = $msguid;
$object['_mailbox'] = $this->name;
}
$result = $this->imap->save_message($this->name, $raw_msg, null, false, null, null, $binary);
// update cache with new UID
if ($result) {
$old_uid = $object['_msguid'];
$object['_msguid'] = $result;
$object['_mailbox'] = $this->name;
if ($old_uid) {
// delete old message
$this->cache->imap_mode(true);
$this->imap->delete_message($old_uid, $object['_mailbox']);
$this->cache->imap_mode(false);
}
// insert/update message in cache
$this->cache->save($result, $object, $old_uid);
}
// remove temp file
if ($body_file) {
@unlink($body_file);
}
}
return $result;
}
/**
* Save recurrence exceptions as individual objects.
* The Kolab v2 format doesn't allow us to save fully embedded exception objects.
*
* @param array Hash array with event properties
* @param string Object type
*/
private function save_recurrence_exceptions(&$object, $type = null)
{
if ($object['recurrence']['EXCEPTIONS']) {
$exdates = array();
foreach ((array)$object['recurrence']['EXDATE'] as $exdate) {
$key = is_a($exdate, 'DateTime') ? $exdate->format('Y-m-d') : strval($exdate);
$exdates[$key] = 1;
}
// save every exception as individual object
foreach((array)$object['recurrence']['EXCEPTIONS'] as $exception) {
$exception['uid'] = self::recurrence_exception_uid($object['uid'], $exception['start']->format('Ymd'));
$exception['sequence'] = $object['sequence'] + 1;
if ($exception['thisandfuture']) {
$exception['recurrence'] = $object['recurrence'];
// adjust the recurrence duration of the exception
if ($object['recurrence']['COUNT']) {
$recurrence = new kolab_date_recurrence($object['_formatobj']);
if ($end = $recurrence->end()) {
unset($exception['recurrence']['COUNT']);
$exception['recurrence']['UNTIL'] = $end;
}
}
// set UNTIL date if we have a thisandfuture exception
$untildate = clone $exception['start'];
$untildate->sub(new DateInterval('P1D'));
$object['recurrence']['UNTIL'] = $untildate;
unset($object['recurrence']['COUNT']);
}
else {
if (!$exdates[$exception['start']->format('Y-m-d')])
$object['recurrence']['EXDATE'][] = clone $exception['start'];
unset($exception['recurrence']);
}
unset($exception['recurrence']['EXCEPTIONS'], $exception['_formatobj'], $exception['_msguid']);
$this->save($exception, $type, $exception['uid']);
}
unset($object['recurrence']['EXCEPTIONS']);
}
}
/**
* Generate an object UID with the given recurrence-ID in a way that it is
* unique (the original UID is not a substring) but still recoverable.
*/
private static function recurrence_exception_uid($uid, $recurrence_id)
{
$offset = -2;
return substr($uid, 0, $offset) . '-' . $recurrence_id . '-' . substr($uid, $offset);
}
/**
* Delete the specified object from this folder.
*
* @param mixed $object The Kolab object to delete or object UID
* @param boolean $expunge Should the folder be expunged?
*
* @return boolean True if successful, false on error
*/
public function delete($object, $expunge = true)
{
if (!$this->valid) {
return false;
}
$msguid = is_array($object) ? $object['_msguid'] : $this->cache->uid2msguid($object);
$success = false;
$this->cache->imap_mode(true);
if ($msguid && $expunge) {
$success = $this->imap->delete_message($msguid, $this->name);
}
else if ($msguid) {
$success = $this->imap->set_flag($msguid, 'DELETED', $this->name);
}
$this->cache->imap_mode(false);
if ($success) {
$this->cache->set($msguid, false);
}
return $success;
}
/**
*
*/
public function delete_all()
{
if (!$this->valid) {
return false;
}
$this->cache->purge();
$this->cache->imap_mode(true);
$result = $this->imap->clear_folder($this->name);
$this->cache->imap_mode(false);
return $result;
}
/**
* Restore a previously deleted object
*
* @param string Object UID
* @return mixed Message UID on success, false on error
*/
public function undelete($uid)
{
if (!$this->valid) {
return false;
}
if ($msguid = $this->cache->uid2msguid($uid, true)) {
$this->cache->imap_mode(true);
$result = $this->imap->set_flag($msguid, 'UNDELETED', $this->name);
$this->cache->imap_mode(false);
if ($result) {
return $msguid;
}
}
return false;
}
/**
* Move a Kolab object message to another IMAP folder
*
* @param string Object UID
* @param string IMAP folder to move object to
* @return boolean True on success, false on failure
*/
public function move($uid, $target_folder)
{
if (!$this->valid) {
return false;
}
if (is_string($target_folder))
$target_folder = kolab_storage::get_folder($target_folder);
if ($msguid = $this->cache->uid2msguid($uid)) {
$this->cache->imap_mode(true);
$result = $this->imap->move_message($msguid, $target_folder->name, $this->name);
$this->cache->imap_mode(false);
if ($result) {
$new_uid = ($copyuid = $this->imap->conn->data['COPYUID']) ? $copyuid[1] : null;
$this->cache->move($msguid, $uid, $target_folder, $new_uid);
return true;
}
else {
rcube::raise_error(array(
'code' => 600, 'type' => 'php',
'file' => __FILE__, 'line' => __LINE__,
'message' => "Failed to move message $msguid to $target_folder: " . $this->imap->get_error_str(),
), true);
}
}
return false;
}
/**
* Creates source of the configuration object message
*
* @param array $object The array that holds the data of the object.
* @param string $type The type of the kolab object.
* @param bool $binary Enables use of binary encoding of attachment(s)
* @param string $body_file Reference to filename of message body
*
* @return mixed Message as string or array with two elements
* (one for message file path, second for message headers)
*/
private function build_message(&$object, $type, $binary, &$body_file)
{
// load old object to preserve data we don't understand/process
if (is_object($object['_formatobj']))
$format = $object['_formatobj'];
else if ($object['_msguid'] && ($old = $this->cache->get($object['_msguid'], $type, $object['_mailbox'])))
$format = $old['_formatobj'];
// create new kolab_format instance
if (!$format)
$format = kolab_format::factory($type, kolab_storage::$version);
if (PEAR::isError($format))
return false;
$format->set($object);
$xml = $format->write(kolab_storage::$version);
$object['uid'] = $format->uid; // read UID from format
$object['_formatobj'] = $format;
if (empty($xml) || !$format->is_valid() || empty($object['uid'])) {
return false;
}
$mime = new Mail_mime("\r\n");
$rcmail = rcube::get_instance();
$headers = array();
$files = array();
$part_id = 1;
$encoding = $binary ? 'binary' : 'base64';
if ($user_email = $rcmail->get_user_email()) {
$headers['From'] = $user_email;
$headers['To'] = $user_email;
}
$headers['Date'] = date('r');
$headers['X-Kolab-Type'] = kolab_format::KTYPE_PREFIX . $type;
$headers['X-Kolab-Mime-Version'] = kolab_storage::$version;
$headers['Subject'] = $object['uid'];
// $headers['Message-ID'] = $rcmail->gen_message_id();
$headers['User-Agent'] = $rcmail->config->get('useragent');
// Check if we have enough memory to handle the message in it
// It's faster than using files, so we'll do this if we only can
if (!empty($object['_attachments']) && ($mem_limit = parse_bytes(ini_get('memory_limit'))) > 0) {
$memory = function_exists('memory_get_usage') ? memory_get_usage() : 16*1024*1024; // safe value: 16MB
foreach ($object['_attachments'] as $attachment) {
$memory += $attachment['size'];
}
// 1.33 is for base64, we need at least 4x more memory than the message size
if ($memory * ($binary ? 1 : 1.33) * 4 > $mem_limit) {
$marker = '%%%~~~' . md5(microtime(true) . $memory) . '~~~%%%';
$is_file = true;
$temp_dir = unslashify($rcmail->config->get('temp_dir'));
$mime->setParam('delay_file_io', true);
}
}
$mime->headers($headers);
$mime->setTXTBody("This is a Kolab Groupware object. "
. "To view this object you will need an email client that understands the Kolab Groupware format. "
. "For a list of such email clients please visit http://www.kolab.org/\n\n");
$ctype = kolab_storage::$version == '2.0' ? $format->CTYPEv2 : $format->CTYPE;
// Convert new lines to \r\n, to wrokaround "NO Message contains bare newlines"
// when APPENDing from temp file
$xml = preg_replace('/\r?\n/', "\r\n", $xml);
$mime->addAttachment($xml, // file
$ctype, // content-type
'kolab.xml', // filename
false, // is_file
'8bit', // encoding
'attachment', // disposition
RCUBE_CHARSET // charset
);
$part_id++;
// save object attachments as separate parts
foreach ((array)$object['_attachments'] as $key => $att) {
if (empty($att['content']) && !empty($att['id'])) {
// @TODO: use IMAP CATENATE to skip attachment fetch+push operation
$msguid = $object['_copyfrom'] ?: ($object['_msguid'] ?: $object['uid']);
if ($is_file) {
$att['path'] = tempnam($temp_dir, 'rcmAttmnt');
if (($fp = fopen($att['path'], 'w')) && $this->get_attachment($msguid, $att['id'], $object['_mailbox'], false, $fp, true)) {
fclose($fp);
}
else {
return false;
}
}
else {
$att['content'] = $this->get_attachment($msguid, $att['id'], $object['_mailbox'], false, null, true);
}
}
$headers = array('Content-ID' => Mail_mimePart::encodeHeader('Content-ID', '<' . $key . '>', RCUBE_CHARSET, 'quoted-printable'));
$name = !empty($att['name']) ? $att['name'] : $key;
// To store binary files we can use faster method
// without writting full message content to a temporary file but
// directly to IMAP, see rcube_imap_generic::append().
// I.e. use file handles where possible
if (!empty($att['path'])) {
if ($is_file && $binary) {
$files[] = fopen($att['path'], 'r');
$mime->addAttachment($marker, $att['mimetype'], $name, false, $encoding, 'attachment', '', '', '', null, null, '', RCUBE_CHARSET, $headers);
}
else {
$mime->addAttachment($att['path'], $att['mimetype'], $name, true, $encoding, 'attachment', '', '', '', null, null, '', RCUBE_CHARSET, $headers);
}
}
else {
if (is_resource($att['content']) && $is_file && $binary) {
$files[] = $att['content'];
$mime->addAttachment($marker, $att['mimetype'], $name, false, $encoding, 'attachment', '', '', '', null, null, '', RCUBE_CHARSET, $headers);
}
else {
if (is_resource($att['content'])) {
@rewind($att['content']);
$att['content'] = stream_get_contents($att['content']);
}
$mime->addAttachment($att['content'], $att['mimetype'], $name, false, $encoding, 'attachment', '', '', '', null, null, '', RCUBE_CHARSET, $headers);
}
}
$object['_attachments'][$key]['id'] = ++$part_id;
}
if (!$is_file || !empty($files)) {
$message = $mime->getMessage();
}
// parse message and build message array with
// attachment file pointers in place of file markers
if (!empty($files)) {
$message = explode($marker, $message);
$tmp = array();
foreach ($message as $msg_part) {
$tmp[] = $msg_part;
if ($file = array_shift($files)) {
$tmp[] = $file;
}
}
$message = $tmp;
}
// write complete message body into temp file
else if ($is_file) {
// use common temp dir
$body_file = tempnam($temp_dir, 'rcmMsg');
if (PEAR::isError($mime_result = $mime->saveMessageBody($body_file))) {
rcube::raise_error(array('code' => 650, 'type' => 'php',
'file' => __FILE__, 'line' => __LINE__,
'message' => "Could not create message: ".$mime_result->getMessage()),
true, false);
return false;
}
$message = array(trim($mime->txtHeaders()) . "\r\n\r\n", fopen($body_file, 'r'));
}
return $message;
}
/**
* Triggers any required updates after changes within the
* folder. This is currently only required for handling free/busy
* information with Kolab.
*
* @return boolean|PEAR_Error True if successfull.
*/
public function trigger()
{
$owner = $this->get_owner();
$result = false;
switch($this->type) {
case 'event':
if ($this->get_namespace() == 'personal') {
$result = $this->trigger_url(
sprintf('%s/trigger/%s/%s.pfb',
kolab_storage::get_freebusy_server(),
urlencode($owner),
urlencode($this->imap->mod_folder($this->name))
),
$this->imap->options['user'],
$this->imap->options['password']
);
}
break;
default:
return true;
}
if ($result && is_object($result) && is_a($result, 'PEAR_Error')) {
return PEAR::raiseError(sprintf("Failed triggering folder %s. Error was: %s",
$this->name, $result->getMessage()));
}
return $result;
}
/**
* Triggers a URL.
*
* @param string $url The URL to be triggered.
* @param string $auth_user Username to authenticate with
* @param string $auth_passwd Password for basic auth
* @return boolean|PEAR_Error True if successfull.
*/
private function trigger_url($url, $auth_user = null, $auth_passwd = null)
{
try {
$request = libkolab::http_request($url);
// set authentication credentials
if ($auth_user && $auth_passwd)
$request->setAuth($auth_user, $auth_passwd);
$result = $request->send();
// rcube::write_log('trigger', $result->getBody());
}
catch (Exception $e) {
return PEAR::raiseError($e->getMessage());
}
return true;
}
/**
* Log content to a file in per_user_loggin dir if configured
*/
private static function save_user_xml($filename, $content)
{
$rcmail = rcube::get_instance();
if ($rcmail->config->get('kolab_format_error_log')) {
$log_dir = $rcmail->config->get('log_dir', RCUBE_INSTALL_PATH . 'logs');
$user_name = $rcmail->get_user_name();
$log_dir = $log_dir . '/' . $user_name;
if (!empty($user_name) && is_writable($log_dir)) {
file_put_contents("$log_dir/$filename", $content);
}
}
}
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sat, Mar 1, 3:37 AM (1 d, 4 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
165662
Default Alt Text
(327 KB)
Attached To
Mode
R14 roundcubemail-plugins-kolab
Attached
Detach File
Event Timeline
Log In to Comment