Page MenuHomePhorge

D3908.1774870103.diff
No OneTemporary

Authored By
Unknown
Size
207 KB
Referenced Files
None
Subscribers
None

D3908.1774870103.diff

diff --git a/plugins/calendar/calendar_ui.js b/plugins/calendar/calendar_ui.js
--- a/plugins/calendar/calendar_ui.js
+++ b/plugins/calendar/calendar_ui.js
@@ -3658,9 +3658,10 @@
});
// register dbl-click handler to open calendar edit dialog
- $(rcmail.gui_objects.calendarslist).on('dblclick', ':not(.virtual) > .calname', function(e){
+ $(rcmail.gui_objects.calendarslist).on('dblclick', ':not(.virtual) > .calname', function(e) {
var id = $(this).closest('li').attr('id').replace(/^rcmlical/, '');
- me.calendar_edit_dialog(me.calendars[id]);
+ if (me.calendars[id] && me.calendars[id].driver != 'caldav')
+ me.calendar_edit_dialog(me.calendars[id]);
});
// Make Elastic checkboxes pretty
diff --git a/plugins/calendar/config.inc.php.dist b/plugins/calendar/config.inc.php.dist
--- a/plugins/calendar/config.inc.php.dist
+++ b/plugins/calendar/config.inc.php.dist
@@ -25,9 +25,12 @@
+-------------------------------------------------------------------------+
*/
-// backend type (database, kolab)
+// backend type (database, kolab, caldav)
$config['calendar_driver'] = "database";
+// CalDAV server location (required when calendar_driver = caldav)
+$config['calendar_caldav_server'] = "http://localhost";
+
// default calendar view (agendaDay, agendaWeek, month)
$config['calendar_default_view'] = "agendaWeek";
diff --git a/plugins/calendar/drivers/caldav/caldav_calendar.php b/plugins/calendar/drivers/caldav/caldav_calendar.php
new file mode 100644
--- /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] = $record = $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 = $record = $this->_to_driver_event($record);
+ }
+
+ if (!empty($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
+ caldav_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);
+ $record['_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
--- /dev/null
+++ b/plugins/calendar/drivers/caldav/caldav_driver.php
@@ -0,0 +1,530 @@
+<?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,
+ // extras to hide some elements in the UI
+ 'subscriptions' => false,
+ 'driver' => 'caldav',
+ ];
+
+ 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 necessary
+ 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
--- /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
--- a/plugins/calendar/drivers/kolab/kolab_driver.php
+++ b/plugins/calendar/drivers/kolab/kolab_driver.php
@@ -37,12 +37,13 @@
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
@@ -56,8 +57,9 @@
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']);
@@ -79,7 +81,7 @@
/**
* Read available calendars from server
*/
- private function _read_calendars()
+ protected function _read_calendars()
{
// already read sources
if (isset($this->calendars)) {
@@ -87,8 +89,8 @@
}
// 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 = [];
@@ -109,7 +111,7 @@
/**
* Convert kolab_storage_folder into kolab_calendar
*/
- private function _to_calendar($folder)
+ protected function _to_calendar($folder)
{
if ($folder instanceof kolab_calendar) {
return $folder;
@@ -152,7 +154,7 @@
// 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);
@@ -163,13 +165,13 @@
// 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;
@@ -385,15 +387,15 @@
$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', []);
@@ -467,22 +469,22 @@
// 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);
}
}
}
@@ -511,7 +513,7 @@
$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']]);
@@ -520,7 +522,7 @@
return true;
}
else {
- $this->last_error = kolab_storage::$last_error;
+ $this->last_error = $this->storage->last_error;
}
}
@@ -537,7 +539,7 @@
*/
public function search_calendars($query, $source)
{
- if (!kolab_storage::setup()) {
+ if (!$this->storage->setup()) {
return [];
}
@@ -546,7 +548,7 @@
// 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;
}
@@ -556,12 +558,12 @@
// 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;
@@ -1001,7 +1003,7 @@
/**
* 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;
@@ -1970,7 +1972,7 @@
/**
*
*/
- 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'])) {
@@ -2014,7 +2016,7 @@
'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
@@ -2501,7 +2503,7 @@
*
* @return array (uid,folder,msguid) tuple
*/
- private function _resolve_event_identity($event)
+ protected function _resolve_event_identity($event)
{
$mailbox = $msguid = null;
@@ -2602,7 +2604,7 @@
// 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);
}
@@ -2614,7 +2616,7 @@
$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',
diff --git a/plugins/calendar/lib/calendar_ui.php b/plugins/calendar/lib/calendar_ui.php
--- a/plugins/calendar/lib/calendar_ui.php
+++ b/plugins/calendar/lib/calendar_ui.php
@@ -381,15 +381,17 @@
);
}
- $content .= html::tag('input', [
- 'type' => 'checkbox',
- 'name' => '_cal[]',
- 'value' => $id,
- 'checked' => !empty($prop['active']),
- 'aria-labelledby' => $label_id
- ])
- . html::span('actions', $actions)
- . html::span(['class' => 'handle', 'style' => "background-color: #$color"], '&nbsp;');
+ if (!isset($prop['subscriptions']) || $prop['subscriptions'] !== false) {
+ $content .= html::tag('input', [
+ 'type' => 'checkbox',
+ 'name' => '_cal[]',
+ 'value' => $id,
+ 'checked' => !empty($prop['active']),
+ 'aria-labelledby' => $label_id
+ ])
+ . html::span('actions', $actions)
+ . html::span(['class' => 'handle', 'style' => "background-color: #$color"], '&nbsp;');
+ }
}
$content = html::div(join(' ', $classes), $content);
diff --git a/plugins/calendar/skins/elastic/templates/calendar.html b/plugins/calendar/skins/elastic/templates/calendar.html
--- a/plugins/calendar/skins/elastic/templates/calendar.html
+++ b/plugins/calendar/skins/elastic/templates/calendar.html
@@ -8,9 +8,11 @@
<div class="header">
<a class="button icon back-content-button" href="#back" data-hidden="big"><span class="inner"><roundcube:label name="back" /></span></a>
<span id="aria-label-calendars" class="header-title"><roundcube:label name="calendar.calendars" /></span>
+ <roundcube:if condition="env:calendar_driver != 'caldav'" />
<roundcube:button name="calendaractionsmenu" id="calendaroptionsmenulink" type="link"
title="calendar.calendaractions" class="button icon sidebar-menu" data-popup="calendaractions-menu"
innerClass="inner" label="actions" />
+ <roundcube:endif />
</div>
<roundcube:object name="libkolab.folder_search_form" id="calendarlistsearch" wrapper="searchbar menu"
ariatag="h2" label="calsearchform" label-domain="calendar" buttontitle="findcalendars" />
diff --git a/plugins/kolab_addressbook/config.inc.php.dist b/plugins/kolab_addressbook/config.inc.php.dist
--- a/plugins/kolab_addressbook/config.inc.php.dist
+++ b/plugins/kolab_addressbook/config.inc.php.dist
@@ -1,5 +1,10 @@
<?php
+// Backend type (kolab, carddav)
+$config['kolab_addressbook_driver'] = "kolab";
+
+// CalDAV server location (required when kolab_addressbook_driver = carddav)
+$config['kolab_addressbook_carddav_server'] = "http://localhost";
// This option allows to set addressbooks priority or to disable some
// of them. Disabled addressbooks will be not shown in the UI. Default: 0.
@@ -15,7 +20,8 @@
// %u - Current webmail user name
// %n - Folder name
// %i - Folder UUID
-// $config['kolab_addressbook_carddav_url'] = 'http://%h/iRony/addressbooks/%u/%i';
+// For example: 'http://%h/iRony/addressbooks/%u/%i'
+$config['kolab_addressbook_carddav_url'] = null;
// Name of LDAP addressbook (a key in ldap_public configuration array) for which
// the CardDAV URI will be displayed if kolab_addressbook_carddav_url is set.
@@ -31,5 +37,3 @@
// ignore these properties and allow modifications which then result in sync errors because the server
// denies such updates.
$config['kolab_addressbook_carddav_ldap'] = '';
-
-?>
diff --git a/plugins/kolab_addressbook/kolab_addressbook.php b/plugins/kolab_addressbook/kolab_addressbook.php
--- a/plugins/kolab_addressbook/kolab_addressbook.php
+++ b/plugins/kolab_addressbook/kolab_addressbook.php
@@ -31,12 +31,13 @@
{
public $task = '?(?!logout).*';
+ public $driver;
+ public $bonnie_api = false;
+
private $sources;
- private $folders;
private $rc;
private $ui;
-
- public $bonnie_api = false;
+ private $driver_class;
const GLOBAL_FIRST = 0;
const PERSONAL_FIRST = 1;
@@ -48,13 +49,17 @@
*/
public function init()
{
- require_once(dirname(__FILE__) . '/lib/rcube_kolab_contacts.php');
-
$this->rc = rcube::get_instance();
// load required plugin
$this->require_plugin('libkolab');
+ $this->load_config();
+
+ $this->driver = $this->rc->config->get('kolab_addressbook_driver') ?: 'kolab';
+ $this->driver_class = 'rcube_' . $this->driver . '_contacts';
+ require_once(dirname(__FILE__) . '/lib/' . $this->driver_class . '.php');
+
// register hooks
$this->add_hook('addressbooks_list', array($this, 'address_sources'));
$this->add_hook('addressbook_get', array($this, 'get_address_book'));
@@ -81,7 +86,6 @@
// Load UI elements
if ($this->api->output->type == 'html') {
- $this->load_config();
require_once($this->home . '/lib/kolab_addressbook_ui.php');
$this->ui = new kolab_addressbook_ui($this);
@@ -103,9 +107,11 @@
$this->add_hook('preferences_save', array($this, 'prefs_save'));
}
- $this->add_hook('folder_delete', array($this, 'prefs_folder_delete'));
- $this->add_hook('folder_rename', array($this, 'prefs_folder_rename'));
- $this->add_hook('folder_update', array($this, 'prefs_folder_update'));
+ if ($this->driver == 'kolab') {
+ $this->add_hook('folder_delete', array($this, 'prefs_folder_delete'));
+ $this->add_hook('folder_rename', array($this, 'prefs_folder_rename'));
+ $this->add_hook('folder_update', array($this, 'prefs_folder_update'));
+ }
}
/**
@@ -211,10 +217,22 @@
$out .= $this->addressbook_list_item($id, $source, $jsdata) . '</li>';
}
+ $filter = function($source) { return !empty($source['kolab']) && empty($source['hidden']); };
+ $folders = array_filter($sources, $filter);
+
// render a hierarchical list of kolab contact folders
- kolab_storage::folder_hierarchy($this->folders, $tree);
- if ($tree && !empty($tree->children)) {
- $out .= $this->folder_tree_html($tree, $sources, $jsdata);
+ // TODO: Move this to the drivers
+ if ($this->driver == 'kolab') {
+ kolab_storage::folder_hierarchy($folders, $tree);
+ if ($tree && !empty($tree->children)) {
+ $out .= $this->folder_tree_html($tree, $sources, $jsdata);
+ }
+ }
+ else {
+ foreach ($folders as $j => $source) {
+ $id = strval(strlen($source['id']) ? $source['id'] : $j);
+ $out .= $this->addressbook_list_item($id, $source, $jsdata) . '</li>';
+ }
}
$this->rc->output->set_env('contactgroups', array_filter($jsdata, function($src){ return $src['type'] == 'group'; }));
@@ -302,7 +320,7 @@
), $name)
);
- if (isset($source['subscribed'])) {
+ if ($this->driver == 'kolab' && isset($source['subscribed'])) {
$inner .= html::span(array(
'class' => 'subscribed',
'title' => $this->gettext('foldersubscribe'),
@@ -395,7 +413,6 @@
return $args;
}
-
/**
* Getter for the rcube_addressbook instance
*
@@ -406,16 +423,8 @@
public function get_address_book($p)
{
if ($p['id']) {
- $id = kolab_storage::id_decode($p['id']);
- $folder = kolab_storage::get_folder($id);
-
- // try with unencoded (old-style) identifier
- if ((!$folder || $folder->type != 'contact') && $id != $p['id']) {
- $folder = kolab_storage::get_folder($p['id']);
- }
-
- if ($folder && $folder->type == 'contact') {
- $p['instance'] = new rcube_kolab_contacts($folder->name);
+ if ($source = $this->driver_class::get_address_book($p['id'])) {
+ $p['instance'] = $source;
// flag source as writeable if 'i' right is given
if ($p['writeable'] && $this->rc->action == 'save' && strpos($p['instance']->rights, 'i') !== false) {
@@ -430,57 +439,33 @@
return $p;
}
-
+ /**
+ * List addressbook sources list
+ */
private function _list_sources()
{
// already read sources
- if (isset($this->sources))
+ if (isset($this->sources)) {
return $this->sources;
+ }
- kolab_storage::$encode_ids = true;
- $this->sources = array();
- $this->folders = array();
+ $this->sources = [];
$abook_prio = $this->addressbook_prio();
// Personal address source(s) disabled?
- if ($abook_prio == self::GLOBAL_ONLY) {
+ if ($abook_prio == kolab_addressbook::GLOBAL_ONLY) {
return $this->sources;
}
// get all folders that have "contact" type
- $folders = kolab_storage::sort_folders(kolab_storage::get_folders('contact'));
-
- if (PEAR::isError($folders)) {
- rcube::raise_error(array(
- 'code' => 600, 'type' => 'php',
- 'file' => __FILE__, 'line' => __LINE__,
- 'message' => "Failed to list contact folders from Kolab server:" . $folders->getMessage()),
- true, false);
- }
- else {
- // we need at least one folder to prevent from errors in Roundcube core
- // when there's also no sql nor ldap addressbook (Bug #2086)
- if (empty($folders)) {
- if ($folder = kolab_storage::create_default_folder('contact')) {
- $folders = array(new kolab_storage_folder($folder, 'contact'));
- }
- }
-
- // convert to UTF8 and sort
- foreach ($folders as $folder) {
- // create instance of rcube_contacts
- $abook_id = $folder->id;
- $abook = new rcube_kolab_contacts($folder->name);
- $this->sources[$abook_id] = $abook;
- $this->folders[$abook_id] = $folder;
- }
+ foreach ($this->driver_class::list_folders() as $id => $source) {
+ $this->sources[$id] = $source;
}
return $this->sources;
}
-
/**
* Plugin hook called before rendering the contact form or detail view
*
@@ -800,17 +785,17 @@
*/
private function _sort_form_fields($contents, $source)
{
- $block = array();
+ $block = [];
- foreach (array_keys($source->coltypes) as $col) {
- if (isset($contents[$col]))
- $block[$col] = $contents[$col];
- }
+ foreach (array_keys($source->coltypes) as $col) {
+ if (isset($contents[$col])) {
+ $block[$col] = $contents[$col];
+ }
+ }
- return $block;
+ return $block;
}
-
/**
* Handler for user preferences form (preferences_list hook)
*
@@ -958,9 +943,9 @@
*/
public function book_search()
{
- $results = array();
- $query = rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC);
- $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC);
+ $results = [];
+ $query = rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC);
+ $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC);
kolab_storage::$encode_ids = true;
$search_more_results = false;
@@ -1108,12 +1093,6 @@
*/
private function addressbook_prio()
{
- // Load configuration
- if (!$this->config_loaded) {
- $this->load_config();
- $this->config_loaded = true;
- }
-
$abook_prio = (int) $this->rc->config->get('kolab_addressbook_prio');
// Make sure any global addressbooks are defined
diff --git a/plugins/kolab_addressbook/lib/kolab_addressbook_ui.php b/plugins/kolab_addressbook/lib/kolab_addressbook_ui.php
--- a/plugins/kolab_addressbook/lib/kolab_addressbook_ui.php
+++ b/plugins/kolab_addressbook/lib/kolab_addressbook_ui.php
@@ -54,6 +54,10 @@
// Include stylesheet (for directorylist)
$this->plugin->include_stylesheet($this->plugin->local_skin_path().'/kolab_addressbook.css');
+ if ($this->plugin->driver != 'kolab') {
+ return;
+ }
+
// include kolab folderlist widget if available
if (in_array('libkolab', $this->plugin->api->loaded_plugins())) {
$this->plugin->api->include_script('libkolab/libkolab.js');
@@ -66,20 +70,20 @@
$idx = 0;
if ($dav_url = $this->rc->config->get('kolab_addressbook_carddav_url')) {
- $options[] = 'book-showurl';
- $this->rc->output->set_env('kolab_addressbook_carddav_url', true);
-
- // set CardDAV URI for specified ldap addressbook
- if ($ldap_abook = $this->rc->config->get('kolab_addressbook_carddav_ldap')) {
- $dav_ldap_url = strtr($dav_url, array(
- '%h' => $_SERVER['HTTP_HOST'],
- '%u' => urlencode($this->rc->get_user_name()),
- '%i' => 'ldap-directory',
- '%n' => '',
- ));
- $this->rc->output->set_env('kolab_addressbook_carddav_ldap', $ldap_abook);
- $this->rc->output->set_env('kolab_addressbook_carddav_ldap_url', $dav_ldap_url);
- }
+ $options[] = 'book-showurl';
+ $this->rc->output->set_env('kolab_addressbook_carddav_url', true);
+
+ // set CardDAV URI for specified ldap addressbook
+ if ($ldap_abook = $this->rc->config->get('kolab_addressbook_carddav_ldap')) {
+ $dav_ldap_url = strtr($dav_url, array(
+ '%h' => $_SERVER['HTTP_HOST'],
+ '%u' => urlencode($this->rc->get_user_name()),
+ '%i' => 'ldap-directory',
+ '%n' => '',
+ ));
+ $this->rc->output->set_env('kolab_addressbook_carddav_ldap', $ldap_abook);
+ $this->rc->output->set_env('kolab_addressbook_carddav_ldap_url', $dav_ldap_url);
+ }
}
foreach ($options as $command) {
diff --git a/plugins/kolab_addressbook/lib/rcube_kolab_contacts.php b/plugins/kolab_addressbook/lib/rcube_carddav_contacts.php
copy from plugins/kolab_addressbook/lib/rcube_kolab_contacts.php
copy to plugins/kolab_addressbook/lib/rcube_carddav_contacts.php
--- a/plugins/kolab_addressbook/lib/rcube_kolab_contacts.php
+++ b/plugins/kolab_addressbook/lib/rcube_carddav_contacts.php
@@ -1,15 +1,15 @@
<?php
/**
- * Backend class for a custom address book
+ * Backend class for a custom address book using CardDAV service.
*
* This part of the Roundcube+Kolab integration and connects the
- * rcube_addressbook interface with the kolab_storage wrapper from libkolab
+ * rcube_addressbook interface with the kolab_storage_dav wrapper from libkolab
*
* @author Thomas Bruederli <bruederli@kolabsys.com>
- * @author Aleksander Machniak <machniak@kolabsys.com>
+ * @author Aleksander Machniak <machniak@apheleia-it.chm>
*
- * Copyright (C) 2011, Kolab Systems AG <contact@kolabsys.com>
+ * Copyright (C) 2011-2022, Kolab Systems 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
@@ -26,95 +26,59 @@
*
* @see rcube_addressbook
*/
-class rcube_kolab_contacts extends rcube_addressbook
+class rcube_carddav_contacts extends rcube_addressbook
{
public $primary_key = 'ID';
public $rights = 'lrs';
public $readonly = true;
- public $undelete = true;
- public $groups = true;
- public $coltypes = array(
- 'name' => array('limit' => 1),
- 'firstname' => array('limit' => 1),
- 'surname' => array('limit' => 1),
- 'middlename' => array('limit' => 1),
- 'prefix' => array('limit' => 1),
- 'suffix' => array('limit' => 1),
- 'nickname' => array('limit' => 1),
- 'jobtitle' => array('limit' => 1),
- 'organization' => array('limit' => 1),
- 'department' => array('limit' => 1),
- 'email' => array('subtypes' => array('home','work','other')),
- 'phone' => array(),
- 'address' => array('subtypes' => array('home','work','office')),
- 'website' => array('subtypes' => array('homepage','blog')),
- 'im' => array('subtypes' => null),
- 'gender' => array('limit' => 1),
- 'birthday' => array('limit' => 1),
- 'anniversary' => array('limit' => 1),
- 'profession' => array(
- 'type' => 'text',
- 'size' => 40,
- 'maxlength' => 80,
- 'limit' => 1,
- 'label' => 'kolab_addressbook.profession',
- 'category' => 'personal'
- ),
- 'manager' => array('limit' => null),
- 'assistant' => array('limit' => null),
- 'spouse' => array('limit' => 1),
- 'children' => array(
- 'type' => 'text',
- 'size' => 40,
- 'maxlength' => 80,
- 'limit' => null,
- 'label' => 'kolab_addressbook.children',
- 'category' => 'personal'
- ),
- 'freebusyurl' => array(
- 'type' => 'text',
- 'size' => 40,
- 'limit' => 1,
- 'label' => 'kolab_addressbook.freebusyurl'
- ),
- 'pgppublickey' => array(
- 'type' => 'textarea',
- 'size' => 70,
- 'rows' => 10,
- 'limit' => 1,
- 'label' => 'kolab_addressbook.pgppublickey'
- ),
- 'pkcs7publickey' => array(
- 'type' => 'textarea',
- 'size' => 70,
- 'rows' => 10,
- 'limit' => 1,
- 'label' => 'kolab_addressbook.pkcs7publickey'
- ),
- 'notes' => array('limit' => 1),
- 'photo' => array('limit' => 1),
- // TODO: define more Kolab-specific fields such as: language, latitude, longitude, crypto settings
- );
-
- /**
- * vCard additional fields mapping
- */
- public $vcard_map = array(
- 'profession' => 'X-PROFESSION',
- 'officelocation' => 'X-OFFICE-LOCATION',
- 'initials' => 'X-INITIALS',
- 'children' => 'X-CHILDREN',
- 'freebusyurl' => 'X-FREEBUSY-URL',
- 'pgppublickey' => 'KEY',
- );
+ public $undelete = false;
+ public $groups = false; // TODO
+
+ public $coltypes = [
+ 'name' => ['limit' => 1],
+ 'firstname' => ['limit' => 1],
+ 'surname' => ['limit' => 1],
+ 'middlename' => ['limit' => 1],
+ 'prefix' => ['limit' => 1],
+ 'suffix' => ['limit' => 1],
+ 'nickname' => ['limit' => 1],
+ 'jobtitle' => ['limit' => 1],
+ 'organization' => ['limit' => 1],
+ 'department' => ['limit' => 1],
+ 'email' => ['subtypes' => ['home','work','other']],
+ 'phone' => [],
+ 'address' => ['subtypes' => ['home','work','office']],
+ 'website' => ['subtypes' => ['homepage','blog']],
+ 'im' => ['subtypes' => null],
+ 'gender' => ['limit' => 1],
+ 'birthday' => ['limit' => 1],
+ 'anniversary' => ['limit' => 1],
+ 'manager' => ['limit' => null],
+ 'assistant' => ['limit' => null],
+ 'spouse' => ['limit' => 1],
+ 'notes' => ['limit' => 1],
+ 'photo' => ['limit' => 1],
+ ];
+
+ public $vcard_map = [
+ // 'profession' => 'X-PROFESSION',
+ // 'officelocation' => 'X-OFFICE-LOCATION',
+ // 'initials' => 'X-INITIALS',
+ // 'children' => 'X-CHILDREN',
+ // 'freebusyurl' => 'X-FREEBUSY-URL',
+ // 'pgppublickey' => 'KEY',
+ 'uid' => 'UID',
+ ];
/**
* List of date type fields
*/
- public $date_cols = array('birthday', 'anniversary');
+ public $date_cols = ['birthday', 'anniversary'];
+
+ public $fulltext_cols = ['name', 'firstname', 'surname', 'middlename', 'email'];
private $gid;
- private $storagefolder;
+ private $storage;
private $dataset;
private $sortindex;
private $contacts;
@@ -123,11 +87,10 @@
private $filter;
private $result;
private $namespace;
- private $imap_folder = 'INBOX/Contacts';
private $action;
// list of fields used for searching in "All fields" mode
- private $search_fields = array(
+ private $search_fields = [
'name',
'firstname',
'surname',
@@ -141,48 +104,31 @@
'email',
'phone',
'address',
- 'profession',
+// 'profession',
'manager',
'assistant',
'spouse',
'children',
'notes',
- );
+ ];
- public function __construct($imap_folder = null)
+ /**
+ * Object constructor
+ */
+ public function __construct($dav_folder = null)
{
- if ($imap_folder) {
- $this->imap_folder = $imap_folder;
- }
-
- // extend coltypes configuration
- $format = kolab_format::factory('contact');
-
- $this->coltypes['phone']['subtypes'] = array_keys($format->phonetypes);
- $this->coltypes['address']['subtypes'] = array_keys($format->addresstypes);
-
- $rcube = rcube::get_instance();
-
- // set localized labels for proprietary cols
- foreach ($this->coltypes as $col => $prop) {
- if (is_string($prop['label'])) {
- $this->coltypes[$col]['label'] = $rcube->gettext($prop['label']);
- }
- }
-
- // fetch objects from the given IMAP folder
- $this->storagefolder = kolab_storage::get_folder($this->imap_folder);
- $this->ready = $this->storagefolder && !PEAR::isError($this->storagefolder);
+ $this->storage = $dav_folder;
+ $this->ready = !empty($this->storage);
// Set readonly and rights flags according to folder permissions
if ($this->ready) {
- if ($this->storagefolder->get_owner() == $_SESSION['username']) {
+ if ($this->storage->get_owner() == $_SESSION['username']) {
$this->readonly = false;
$this->rights = 'lrswikxtea';
}
else {
- $rights = $this->storagefolder->get_myrights();
+ $rights = $this->storage->get_myrights();
if ($rights && !PEAR::isError($rights)) {
$this->rights = $rights;
if (strpos($rights, 'i') !== false && strpos($rights, 't') !== false) {
@@ -202,7 +148,7 @@
*/
public function get_name()
{
- return $this->storagefolder->get_name();
+ return $this->storage->get_name();
}
/**
@@ -210,17 +156,17 @@
*/
public function get_foldername()
{
- return $this->storagefolder->get_foldername();
+ return $this->storage->get_foldername();
}
/**
- * Getter for the IMAP folder name
+ * Getter for the folder name
*
- * @return string Name of the IMAP folder
+ * @return string Name of the folder
*/
public function get_realname()
{
- return $this->imap_folder;
+ return $this->get_name();
}
/**
@@ -231,7 +177,7 @@
public function get_namespace()
{
if ($this->namespace === null && $this->ready) {
- $this->namespace = $this->storagefolder->get_namespace();
+ $this->namespace = $this->storage->get_namespace();
}
return $this->namespace;
@@ -244,7 +190,7 @@
*/
public function get_parent()
{
- return $this->storagefolder->get_parent();
+ return $this->storage->get_parent();
}
/**
@@ -254,7 +200,7 @@
*/
public function is_subscribed()
{
- return kolab_storage::folder_is_subscribed($this->imap_folder);
+ return true;
}
/**
@@ -262,16 +208,17 @@
*/
public function get_carddav_url()
{
+/*
$rcmail = rcmail::get_instance();
if ($template = $rcmail->config->get('kolab_addressbook_carddav_url', null)) {
- return strtr($template, array(
+ return strtr($template, [
'%h' => $_SERVER['HTTP_HOST'],
'%u' => urlencode($rcmail->get_user_name()),
- '%i' => urlencode($this->storagefolder->get_uid()),
+ '%i' => urlencode($this->storage->get_uid()),
'%n' => urlencode($this->imap_folder),
- ));
+ ]);
}
-
+*/
return false;
}
@@ -312,6 +259,50 @@
$this->filter = null;
}
+ /**
+ * List addressbook sources (folders)
+ */
+ public static function list_folders()
+ {
+ $storage = self::get_storage();
+ $sources = [];
+
+ // get all folders that have "contact" type
+ foreach ($storage->get_folders('contact') as $folder) {
+ $sources[$folder->id] = new rcube_carddav_contacts($folder);
+ }
+
+ return $sources;
+ }
+
+ /**
+ * Getter for the rcube_addressbook instance
+ *
+ * @param string $id Addressbook (folder) ID
+ *
+ * @return ?rcube_carddav_contacts
+ */
+ public static function get_address_book($id)
+ {
+ $storage = self::get_storage();
+ $folder = $storage->get_folder($id, 'contact');
+
+ if ($folder) {
+ return new rcube_carddav_contacts($folder);
+ }
+ }
+
+ /**
+ * Initialize kolab_storage_dav instance
+ */
+ protected static function get_storage()
+ {
+ $rcube = rcube::get_instance();
+ $url = $rcube->config->get('kolab_addressbook_carddav_server', 'http://localhost');
+
+ return new kolab_storage_dav($url);
+ }
+
/**
* List all active contact groups of this source
*
@@ -323,11 +314,11 @@
function list_groups($search = null, $mode = 0)
{
$this->_fetch_groups();
- $groups = array();
+ $groups = [];
foreach ((array)$this->distlists as $group) {
if (!$search || strstr(mb_strtolower($group['name']), mb_strtolower($search))) {
- $groups[$group['ID']] = array('ID' => $group['ID'], 'name' => $group['name']);
+ $groups[$group['ID']] = ['ID' => $group['ID'], 'name' => $group['name']];
}
}
@@ -357,10 +348,10 @@
if ($this->gid) {
$this->_fetch_groups();
- $this->sortindex = array();
- $this->contacts = array();
- $local_sortindex = array();
- $uids = array();
+ $this->sortindex = [];
+ $this->contacts = [];
+ $local_sortindex = [];
+ $uids = [];
// get members with email specified
foreach ((array)$this->distlists[$this->gid]['member'] as $member) {
@@ -381,15 +372,15 @@
// get members by UID
if (!empty($uids)) {
- $this->_fetch_contacts($query = array(array('uid', '=', $uids)), $fetch_all ? false : count($uids), $fast_mode);
+ $this->_fetch_contacts($query = [['uid', '=', $uids]], $fetch_all ? false : count($uids), $fast_mode);
$this->sortindex = array_merge($this->sortindex, $local_sortindex);
}
}
else if (is_array($this->filter['ids'])) {
$ids = $this->filter['ids'];
if (count($ids)) {
- $uids = array_map(array($this, 'id2uid'), $this->filter['ids']);
- $this->_fetch_contacts($query = array(array('uid', '=', $uids)), count($ids), $fast_mode);
+ $uids = array_map([$this, 'id2uid'], $this->filter['ids']);
+ $this->_fetch_contacts($query = [['uid', '=', $uids]], count($ids), $fast_mode);
}
}
else {
@@ -419,7 +410,7 @@
$this->result->count = count($this->dataset) + $this->page_size * ($this->list_page - 1);
}
else {
- $this->result->count = $this->storagefolder->count($query);
+ $this->result->count = $this->storage->count($query);
}
$start_row = $subset < 0 ? $this->page_size + $subset : 0;
@@ -449,7 +440,7 @@
*
* @return rcube_result_set List of contact records and 'count' value
*/
- public function search($fields, $value, $mode=0, $select=true, $nocount=false, $required=array())
+ public function search($fields, $value, $mode = 0, $select = true, $nocount = false, $required = [])
{
// search by ID
if ($fields == $this->primary_key) {
@@ -469,10 +460,10 @@
}
if (!is_array($fields)) {
- $fields = array($fields);
+ $fields = [$fields];
}
if (!is_array($required) && !empty($required)) {
- $required = array($required);
+ $required = [$required];
}
// advanced search
@@ -494,16 +485,16 @@
// add magic selector to select contacts with birthday dates only
if (in_array('birthday', $required)) {
- $squery[] = array('tags', '=', 'x-has-birthday');
+ $squery[] = ['tags', '=', 'x-has-birthday'];
}
- $squery[] = array('type', '=', 'contact');
+ $squery[] = ['type', '=', 'contact'];
// get all/matching records
$this->_fetch_contacts($squery);
// save searching conditions
- $this->filter = array('fields' => $fields, 'value' => $value, 'mode' => $mode, 'ids' => array());
+ $this->filter = ['fields' => $fields, 'value' => $value, 'mode' => $mode, 'ids' => []];
// search by iterating over all records in dataset
foreach ($this->dataset as $record) {
@@ -520,8 +511,9 @@
}
}
- $found = array();
+ $found = [];
$contents = '';
+
foreach (preg_grep($regexp, array_keys($contact)) as $col) {
$pos = strpos($col, ':');
$colname = $pos ? substr($col, 0, $pos) : $col;
@@ -579,7 +571,7 @@
$count = count($this->filter['ids']);
}
else {
- $count = $this->storagefolder->count('contact');
+ $count = $this->storage->count('contact');
}
return new rcube_result_set($count, ($this->list_page-1) * $this->page_size);
@@ -614,6 +606,7 @@
$rec = $this->contacts[$id];
$this->readonly = true; // set source to read-only
}
+/*
else if (!empty($rev)) {
$rcmail = rcube::get_instance();
$plugin = $rcmail->plugins->get_plugin('kolab_addressbook');
@@ -623,7 +616,8 @@
}
$this->readonly = true; // set source to read-only
}
- else if ($object = $this->storagefolder->get_object($uid)) {
+*/
+ else if ($object = $this->storage->get_object($uid)) {
$rec = $this->_to_rcube_contact($object);
}
@@ -645,7 +639,7 @@
*/
public function get_record_groups($id)
{
- $out = array();
+ $out = [];
$this->_fetch_groups();
if (!empty($this->groupmembers[$id])) {
@@ -689,22 +683,22 @@
}
if (!$existing) {
- // remove existing id attributes (#1101)
+ // Unset contact ID (e.g. when copying/moving from another addressbook)
unset($save_data['ID'], $save_data['uid']);
// generate new Kolab contact item
$object = $this->_from_rcube_contact($save_data);
- $saved = $this->storagefolder->save($object, 'contact');
+ $saved = $this->storage->save($object, 'contact');
if (!$saved) {
- rcube::raise_error(array(
- 'code' => 600, 'type' => 'php',
- 'file' => __FILE__, 'line' => __LINE__,
- 'message' => "Error saving contact object to Kolab server"),
- true, false);
+ rcube::raise_error([
+ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__,
+ 'message' => "Error saving contact object to CardDAV server"
+ ],
+ true, false);
}
else {
- $insert_id = $this->uid2id($object['uid']);
+ $insert_id = $object['uid'];
}
}
@@ -724,14 +718,14 @@
public function update($id, $save_data)
{
$updated = false;
- if ($old = $this->storagefolder->get_object($this->id2uid($id))) {
+ if ($old = $this->storage->get_object($this->id2uid($id))) {
$object = $this->_from_rcube_contact($save_data, $old);
- if (!$this->storagefolder->save($object, 'contact', $old['uid'])) {
- rcube::raise_error(array(
+ if (!$this->storage->save($object, 'contact', $old['uid'])) {
+ rcube::raise_error([
'code' => 600, 'file' => __FILE__, 'line' => __LINE__,
- 'message' => "Error saving contact object to Kolab server"
- ),
+ 'message' => "Error saving contact object to CardDAV server"
+ ],
true, false
);
}
@@ -753,7 +747,7 @@
*
* @return int Number of records deleted
*/
- public function delete($ids, $force=true)
+ public function delete($ids, $force = true)
{
$this->_fetch_groups();
@@ -765,13 +759,13 @@
foreach ($ids as $id) {
if ($uid = $this->id2uid($id)) {
$is_mailto = strpos($uid, 'mailto:') === 0;
- $deleted = $is_mailto || $this->storagefolder->delete($uid, $force);
+ $deleted = $is_mailto || $this->storage->delete($uid, $force);
if (!$deleted) {
- rcube::raise_error(array(
+ rcube::raise_error([
'code' => 600, 'file' => __FILE__, 'line' => __LINE__,
- 'message' => "Error deleting a contact object $uid from the Kolab server"
- ),
+ 'message' => "Error deleting a contact object $uid from the CardDAV server"
+ ],
true, false
);
}
@@ -810,14 +804,14 @@
$count = 0;
foreach ($ids as $id) {
$uid = $this->id2uid($id);
- if ($this->storagefolder->undelete($uid)) {
+ if ($this->storage->undelete($uid)) {
$count++;
}
else {
- rcube::raise_error(array(
+ rcube::raise_error([
'code' => 600, 'file' => __FILE__, 'line' => __LINE__,
- 'message' => "Error undeleting a contact object $uid from the Kolab server"
- ),
+ 'message' => "Error undeleting a contact object $uid from the CardDav server"
+ ],
true, false
);
}
@@ -833,9 +827,9 @@
*/
public function delete_all($with_groups = false)
{
- if ($this->storagefolder->delete_all()) {
- $this->contacts = array();
- $this->sortindex = array();
+ if ($this->storage->delete_all()) {
+ $this->contacts = [];
+ $this->sortindex = [];
$this->dataset = null;
$this->result = null;
}
@@ -847,6 +841,7 @@
*/
public function close()
{
+ // NOP
}
/**
@@ -861,17 +856,17 @@
$this->_fetch_groups();
$result = false;
- $list = array(
+ $list = [
'name' => $name,
- 'member' => array(),
- );
- $saved = $this->storagefolder->save($list, 'distribution-list');
+ 'member' => [],
+ ];
+ $saved = $this->storage->save($list, 'distribution-list');
if (!$saved) {
- rcube::raise_error(array(
+ rcube::raise_error([
'code' => 600, 'file' => __FILE__, 'line' => __LINE__,
- 'message' => "Error saving distribution-list object to Kolab server"
- ),
+ 'message' => "Error saving distribution-list object to CardDAV server"
+ ],
true, false
);
return false;
@@ -879,7 +874,7 @@
else {
$id = $this->uid2id($list['uid']);
$this->distlists[$id] = $list;
- $result = array('id' => $id, 'name' => $name);
+ $result = ['id' => $id, 'name' => $name];
}
return $result;
@@ -898,14 +893,14 @@
$result = false;
if ($list = $this->distlists[$gid]) {
- $deleted = $this->storagefolder->delete($list['uid']);
+ $deleted = $this->storage->delete($list['uid']);
}
if (!$deleted) {
- rcube::raise_error(array(
+ rcube::raise_error([
'code' => 600, 'file' => __FILE__, 'line' => __LINE__,
- 'message' => "Error deleting distribution-list object from the Kolab server"
- ),
+ 'message' => "Error deleting distribution-list object from the CardDAV server"
+ ],
true, false
);
}
@@ -932,14 +927,14 @@
if ($newname != $list['name']) {
$list['name'] = $newname;
- $saved = $this->storagefolder->save($list, 'distribution-list', $list['uid']);
+ $saved = $this->storage->save($list, 'distribution-list', $list['uid']);
}
if (!$saved) {
- rcube::raise_error(array(
+ rcube::raise_error([
'code' => 600, 'file' => __FILE__, 'line' => __LINE__,
- 'message' => "Error saving distribution-list object to Kolab server"
- ),
+ 'message' => "Error saving distribution-list object to CardDAV server"
+ ],
true, false
);
return false;
@@ -965,8 +960,8 @@
$list = $this->distlists[$gid];
$added = 0;
- $uids = array();
- $exists = array();
+ $uids = [];
+ $exists = [];
foreach ((array)$list['member'] as $member) {
$exists[] = $member['ID'];
@@ -979,10 +974,10 @@
foreach ($ids as $contact_id) {
$uid = $this->id2uid($contact_id);
if (strpos($uid, 'mailto:') === 0 && ($contact = $this->contacts[$contact_id])) {
- $list['member'][] = array(
+ $list['member'][] = [
'email' => $contact['email'],
'name' => $contact['name'],
- );
+ ];
$this->groupmembers[$contact_id][] = $gid;
$added++;
}
@@ -994,24 +989,24 @@
// add members with UID
if (!empty($uids)) {
foreach ($uids as $uid => $contact_id) {
- $list['member'][] = array('uid' => $uid);
+ $list['member'][] = ['uid' => $uid];
$this->groupmembers[$contact_id][] = $gid;
$added++;
}
}
if ($added) {
- $saved = $this->storagefolder->save($list, 'distribution-list', $list['uid']);
+ $saved = $this->storage->save($list, 'distribution-list', $list['uid']);
}
else {
$saved = true;
}
if (!$saved) {
- rcube::raise_error(array(
+ rcube::raise_error([
'code' => 600, 'file' => __FILE__, 'line' => __LINE__,
- 'message' => "Error saving distribution-list to Kolab server"
- ),
+ 'message' => "Error saving distribution-list to CardDAV server"
+ ],
true, false
);
@@ -1030,7 +1025,8 @@
*
* @param string Group identifier
* @param array List of contact identifiers to be removed
- * @return int Number of deleted group members
+ *
+ * @return bool
*/
function remove_from_group($gid, $ids)
{
@@ -1043,8 +1039,8 @@
return false;
}
- $new_member = array();
- foreach ((array)$list['member'] as $member) {
+ $new_member = [];
+ foreach ((array) $list['member'] as $member) {
if (!in_array($member['ID'], $ids)) {
$new_member[] = $member;
}
@@ -1052,13 +1048,13 @@
// write distribution list back to server
$list['member'] = $new_member;
- $saved = $this->storagefolder->save($list, 'distribution-list', $list['uid']);
+ $saved = $this->storage->save($list, 'distribution-list', $list['uid']);
if (!$saved) {
- rcube::raise_error(array(
+ rcube::raise_error([
'code' => 600, 'file' => __FILE__, 'line' => __LINE__,
- 'message' => "Error saving distribution-list object to Kolab server"
- ),
+ 'message' => "Error saving distribution-list object to CardDAV server"
+ ],
true, false
);
}
@@ -1107,16 +1103,16 @@
/**
* Query storage layer and store records in private member var
*/
- private function _fetch_contacts($query = array(), $limit = false, $fast_mode = false)
+ private function _fetch_contacts($query = [], $limit = false, $fast_mode = false)
{
if (!isset($this->dataset) || !empty($query)) {
if ($limit) {
$size = is_int($limit) && $limit < $this->page_size ? $limit : $this->page_size;
- $this->storagefolder->set_order_and_limit($this->_sort_columns(), $size, ($this->list_page-1) * $this->page_size);
+ $this->storage->set_order_and_limit($this->_sort_columns(), $size, ($this->list_page-1) * $this->page_size);
}
- $this->sortindex = array();
- $this->dataset = $this->storagefolder->select($query, $fast_mode);
+ $this->sortindex = [];
+ $this->dataset = $this->storage->select($query, $fast_mode);
foreach ($this->dataset as $idx => $record) {
$contact = $this->_to_rcube_contact($record);
@@ -1149,6 +1145,7 @@
}
$str .= is_array($rec['email']) ? $rec['email'][0] : $rec['email'];
+
return mb_strtolower($str);
}
@@ -1157,11 +1154,12 @@
*/
private function _sort_columns()
{
- $sortcols = array();
+ $sortcols = [];
switch ($this->sort_col) {
case 'name':
$sortcols[] = 'name';
+
case 'firstname':
$sortcols[] = 'firstname';
break;
@@ -1180,9 +1178,11 @@
*/
private function _fetch_groups($with_contacts = false)
{
+ return; // TODO
+
if (!isset($this->distlists)) {
- $this->distlists = $this->groupmembers = array();
- foreach ($this->storagefolder->select('distribution-list', true) as $record) {
+ $this->distlists = $this->groupmembers = [];
+ foreach ($this->storage->select('distribution-list', true) as $record) {
$record['ID'] = $this->uid2id($record['uid']);
foreach ((array)$record['member'] as $i => $member) {
$mid = $this->uid2id($member['uid'] ? $member['uid'] : 'mailto:' . $member['email']);
@@ -1220,20 +1220,12 @@
*/
private function _search_query($fields, $value, $mode)
{
- $query = array();
- $cols = array();
+ $query = [];
+ $cols = [];
- // $fulltext_cols might contain composite field names e.g. 'email:address' while $fields not
- foreach (kolab_format_contact::$fulltext_cols as $col) {
- if ($pos = strpos($col, ':')) {
- $col = substr($col, 0, $pos);
- }
- if (in_array($col, $fields)) {
- $cols[] = $col;
- }
- }
+ $cols = array_intersect($fields, $this->fulltext_cols);
- if (count($cols) == count($fields)) {
+ if (count($cols)) {
if ($mode & rcube_addressbook::SEARCH_STRICT) {
$prefix = '^'; $suffix = '$';
}
@@ -1246,7 +1238,7 @@
$search_string = is_array($value) ? join(' ', $value) : $value;
foreach (rcube_utils::normalize_string($search_string, true) as $word) {
- $query[] = array('words', 'LIKE', $prefix . $word . $suffix);
+ $query[] = ['words', 'LIKE', $prefix . $word . $suffix];
}
}
@@ -1260,57 +1252,11 @@
{
$record['ID'] = $this->uid2id($record['uid']);
- // convert email, website, phone values
- foreach (array('email'=>'address', 'website'=>'url', 'phone'=>'number') as $col => $propname) {
- if (is_array($record[$col])) {
- $values = $record[$col];
- unset($record[$col]);
- foreach ((array)$values as $i => $val) {
- $key = $col . ($val['type'] ? ':' . $val['type'] : '');
- $record[$key][] = $val[$propname];
- }
- }
- }
-
- if (is_array($record['address'])) {
- $addresses = $record['address'];
- unset($record['address']);
- foreach ($addresses as $i => $adr) {
- $key = 'address' . ($adr['type'] ? ':' . $adr['type'] : '');
- $record[$key][] = array(
- 'street' => $adr['street'],
- 'locality' => $adr['locality'],
- 'zipcode' => $adr['code'],
- 'region' => $adr['region'],
- 'country' => $adr['country'],
- );
- }
- }
-
- // photo is stored as separate attachment
- if ($record['photo'] && strlen($record['photo']) < 255 && !empty($record['_attachments'][$record['photo']])) {
- $att = $record['_attachments'][$record['photo']];
- // only fetch photo content if requested
- if ($this->action == 'photo') {
- if (!empty($att['content'])) {
- $record['photo'] = $att['content'];
- }
- else {
- $record['photo'] = $this->storagefolder->get_attachment($record['uid'], $att['id']);
- }
- }
- }
-
- // truncate publickey value for display
- if (!empty($record['pgppublickey']) && $this->action == 'show') {
- $record['pgppublickey'] = substr($record['pgppublickey'], 0, 140) . '...';
- }
-
// remove empty fields
$record = array_filter($record);
- // remove kolab_storage internal data
- unset($record['_msguid'], $record['_formatobj'], $record['_mailbox'], $record['_type'], $record['_size']);
+ // Set _type for proper icon on the list
+ $record['_type'] = 'person';
return $record;
}
@@ -1318,83 +1264,22 @@
/**
* Map fields from Roundcube format to internal kolab_format_contact properties
*/
- private function _from_rcube_contact($contact, $old = array())
+ private function _from_rcube_contact($contact, $old = [])
{
- if (!$contact['uid'] && $contact['ID']) {
+ if (empty($contact['uid']) && !empty($contact['ID'])) {
$contact['uid'] = $this->id2uid($contact['ID']);
}
- else if (!$contact['uid'] && $old['uid']) {
+ else if (empty($contact['uid']) && !empty($old['uid'])) {
$contact['uid'] = $old['uid'];
}
-
- $contact['im'] = array_filter($this->get_col_values('im', $contact, true));
-
- // convert email, website, phone values
- foreach (array('email'=>'address', 'website'=>'url', 'phone'=>'number') as $col => $propname) {
- $col_values = $this->get_col_values($col, $contact);
- $contact[$col] = array();
- foreach ($col_values as $type => $values) {
- foreach ((array)$values as $val) {
- if (!empty($val)) {
- $contact[$col][] = array($propname => $val, 'type' => $type);
- }
- }
- unset($contact[$col.':'.$type]);
- }
- }
-
- $addresses = array();
- foreach ($this->get_col_values('address', $contact) as $type => $values) {
- foreach ((array)$values as $adr) {
- // skip empty address
- $adr = array_filter($adr);
- if (empty($adr)) {
- continue;
- }
-
- $addresses[] = array(
- 'type' => $type,
- 'street' => $adr['street'],
- 'locality' => $adr['locality'],
- 'code' => $adr['zipcode'],
- 'region' => $adr['region'],
- 'country' => $adr['country'],
- );
- }
-
- unset($contact['address:'.$type]);
+ else if (empty($contact['uid'])) {
+ $rcube = rcube::get_instance();
+ $contact['uid'] = strtoupper(md5(time() . uniqid(rand())) . '-' . substr(md5($rcube->user->get_username()), 0, 16));
}
- $contact['address'] = $addresses;
-
- // categories are not supported in the web client but should be preserved (#2608)
- $contact['categories'] = $old['categories'];
-
- // copy meta data (starting with _) from old object
- foreach ((array)$old as $key => $val) {
- if (!isset($contact[$key]) && $key[0] == '_') {
- $contact[$key] = $val;
- }
- }
-
- // convert one-item-array elements into string element
- // this is needed e.g. to properly import birthday field
- foreach ($this->coltypes as $type => $col_def) {
- if ($col_def['limit'] == 1 && is_array($contact[$type])) {
- $contact[$type] = array_shift(array_filter($contact[$type]));
- }
- }
-
- // When importing contacts 'vcard' data is added, we don't need it (Bug #1711)
+ // When importing contacts 'vcard' data might be added, we don't need it (Bug #1711)
unset($contact['vcard']);
- // add empty values for some fields which can be removed in the UI
- return array_filter($contact) + array(
- 'nickname' => '',
- 'birthday' => '',
- 'anniversary' => '',
- 'freebusyurl' => '',
- 'photo' => $contact['photo']
- );
+ return $contact;
}
}
diff --git a/plugins/kolab_addressbook/lib/rcube_kolab_contacts.php b/plugins/kolab_addressbook/lib/rcube_kolab_contacts.php
--- a/plugins/kolab_addressbook/lib/rcube_kolab_contacts.php
+++ b/plugins/kolab_addressbook/lib/rcube_kolab_contacts.php
@@ -312,6 +312,64 @@
$this->filter = null;
}
+ /**
+ * List addressbook sources (folders)
+ */
+ public static function list_folders()
+ {
+ kolab_storage::$encode_ids = true;
+
+ // get all folders that have "contact" type
+ $folders = kolab_storage::sort_folders(kolab_storage::get_folders('contact'));
+
+ if (PEAR::isError($folders)) {
+ rcube::raise_error([
+ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__,
+ 'message' => "Failed to list contact folders from Kolab server:" . $folders->getMessage()
+ ],
+ true, false);
+
+ return [];
+ }
+
+ // we need at least one folder to prevent from errors in Roundcube core
+ // when there's also no sql nor ldap addressbook (Bug #2086)
+ if (empty($folders)) {
+ if ($folder = kolab_storage::create_default_folder('contact')) {
+ $folders = [new kolab_storage_folder($folder, 'contact')];
+ }
+ }
+
+ $sources = [];
+ foreach ($folders as $folder) {
+ $sources[$folder->id] = new rcube_kolab_contacts($folder->name);
+ }
+
+ return $sources;
+ }
+
+ /**
+ * Getter for the rcube_addressbook instance
+ *
+ * @param string $id Addressbook (folder) ID
+ *
+ * @return ?rcube_kolab_contacts
+ */
+ public static function get_address_book($id)
+ {
+ $folderId = kolab_storage::id_decode($id);
+ $folder = kolab_storage::get_folder($folderId);
+
+ // try with unencoded (old-style) identifier
+ if ((!$folder || $folder->type != 'contact') && $folderId != $id) {
+ $folder = kolab_storage::get_folder($id);
+ }
+
+ if ($folder && $folder->type == 'contact') {
+ return new rcube_kolab_contacts($folder->name);
+ }
+ }
+
/**
* List all active contact groups of this source
*
diff --git a/plugins/libcalendaring/libvcalendar.php b/plugins/libcalendaring/libvcalendar.php
--- a/plugins/libcalendaring/libvcalendar.php
+++ b/plugins/libcalendaring/libvcalendar.php
@@ -1353,6 +1353,9 @@
if (!empty($ex['thisandfuture'])) {
$recurrence_id->add('RANGE', 'THISANDFUTURE');
}
+
+ $ex['uid'] = $ve->UID;
+
$this->_to_ical($ex, $vcal, $get_attachment, $recurrence_id);
}
}
diff --git a/plugins/libkolab/SQL/mysql.initial.sql b/plugins/libkolab/SQL/mysql.initial.sql
--- a/plugins/libkolab/SQL/mysql.initial.sql
+++ b/plugins/libkolab/SQL/mysql.initial.sql
@@ -1,10 +1,3 @@
-/**
- * libkolab database schema
- *
- * @author Thomas Bruederli
- * @licence GNU AGPL
- */
-
SET FOREIGN_KEY_CHECKS=0;
DROP TABLE IF EXISTS `kolab_folders`;
@@ -177,6 +170,46 @@
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_contact`;
+
+CREATE TABLE `kolab_cache_dav_contact` (
+ `folder_id` BIGINT UNSIGNED NOT NULL,
+ `uid` VARCHAR(512) NOT NULL,
+ `etag` VARCHAR(128) DEFAULT 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_dav_contact_folder` FOREIGN KEY (`folder_id`)
+ REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE,
+ PRIMARY KEY(`folder_id`,`uid`),
+ INDEX `contact_type` (`folder_id`,`type`)
+) 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,
+ `etag` VARCHAR(128) DEFAULT 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/SQL/mysql/2022100500.sql b/plugins/libkolab/SQL/mysql/2022100500.sql
new file mode 100644
--- /dev/null
+++ b/plugins/libkolab/SQL/mysql/2022100500.sql
@@ -0,0 +1,39 @@
+DROP TABLE IF EXISTS `kolab_cache_dav_contact`;
+
+CREATE TABLE `kolab_cache_dav_contact` (
+ `folder_id` BIGINT UNSIGNED NOT NULL,
+ `uid` VARCHAR(512) NOT NULL,
+ `etag` VARCHAR(128) DEFAULT 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_dav_contact_folder` FOREIGN KEY (`folder_id`)
+ REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE,
+ PRIMARY KEY(`folder_id`,`uid`),
+ INDEX `contact_type` (`folder_id`,`type`)
+) 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,
+ `etag` VARCHAR(128) DEFAULT 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;
diff --git a/plugins/libkolab/SQL/oracle.initial.sql b/plugins/libkolab/SQL/oracle.initial.sql
--- a/plugins/libkolab/SQL/oracle.initial.sql
+++ b/plugins/libkolab/SQL/oracle.initial.sql
@@ -1,10 +1,3 @@
-/**
- * libkolab database schema
- *
- * @author Aleksander Machniak
- * @licence GNU AGPL
- */
-
CREATE TABLE "kolab_folders" (
"folder_id" number NOT NULL PRIMARY KEY,
"resource" VARCHAR(255) NOT NULL,
diff --git a/plugins/libkolab/SQL/sqlite.initial.sql b/plugins/libkolab/SQL/sqlite.initial.sql
--- a/plugins/libkolab/SQL/sqlite.initial.sql
+++ b/plugins/libkolab/SQL/sqlite.initial.sql
@@ -1,10 +1,3 @@
-/**
- * libkolab database schema
- *
- * @author Thomas Bruederli
- * @licence GNU AGPL
- */
-
CREATE TABLE kolab_folders (
folder_id INTEGER NOT NULL PRIMARY KEY,
resource VARCHAR(255) NOT NULL,
diff --git a/plugins/libkolab/lib/kolab_dav_client.php b/plugins/libkolab/lib/kolab_dav_client.php
new file mode 100644
--- /dev/null
+++ b/plugins/libkolab/lib/kolab_dav_client.php
@@ -0,0 +1,571 @@
+<?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 $user;
+ protected $password;
+ protected $rc;
+ protected $responseHeaders = [];
+
+ /**
+ * Object constructor
+ */
+ public function __construct($url)
+ {
+ $this->rc = rcube::get_instance();
+
+ $parsedUrl = parse_url($url);
+
+ if (!empty($parsedUrl['user']) && !empty($parsedUrl['pass'])) {
+ $this->user = rawurldecode($parsedUrl['user']);
+ $this->password = rawurldecode($parsedUrl['pass']);
+
+ $url = str_replace(rawurlencode($this->user) . ':' . rawurlencode($this->password) . '@', '', $url);
+ }
+ else {
+ $this->user = $this->rc->user->get_username();
+ $this->password = $this->rc->decrypt($_SESSION['password']);
+ }
+
+ $this->url = $url;
+ }
+
+ /**
+ * 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->user, $this->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')
+ {
+ $roots = [
+ 'VEVENT' => 'calendars',
+ 'VTODO' => 'calendars',
+ 'VCARD' => 'addressbooks',
+ ];
+
+ $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>';
+
+ // Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it)
+ $response = $this->request('/' . $roots[$component], 'PROPFIND', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']);
+
+ $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));
+ }
+
+ $homes = [
+ 'VEVENT' => 'calendar-home-set',
+ 'VTODO' => 'calendar-home-set',
+ 'VCARD' => 'addressbook-home-set',
+ ];
+
+ $ns = [
+ 'VEVENT' => 'caldav',
+ 'VTODO' => 'caldav',
+ 'VCARD' => 'carddav',
+ ];
+
+ $body = '<?xml version="1.0" encoding="utf-8"?>'
+ . '<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:' . $ns[$component] . '">'
+ . '<d:prop>'
+ . '<c:' . $homes[$component] . ' />'
+ . '</d:prop>'
+ . '</d:propfind>';
+
+ $response = $this->request($principal_href, 'PROPFIND', $body);
+
+ $elements = $response->getElementsByTagName('response');
+
+ foreach ($elements as $element) {
+ foreach ($element->getElementsByTagName('prop') as $prop) {
+ $root_href = $prop->nodeValue;
+ break;
+ }
+ }
+
+ if (!empty($root_href)) {
+ if ($path && strpos($root_href, $path) === 0) {
+ $root_href = substr($root_href, strlen($path));
+ }
+ }
+ else {
+ // Kolab iRony's calendar root
+ $root_href = '/' . $roots[$component] . '/' . rawurlencode($this->user);
+ }
+
+ if ($component == 'VCARD') {
+ $add_ns = '';
+ $add_props = '';
+ }
+ else {
+ $add_ns = ' xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:a="http://apple.com/ns/ical/"';
+ $add_props = '<c:supported-calendar-component-set /><a:calendar-color />';
+ }
+
+ $body = '<?xml version="1.0" encoding="utf-8"?>'
+ . '<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/"' . $add_ns . '>'
+ . '<d:prop>'
+ . '<d:resourcetype />'
+ . '<d:displayname />'
+ // . '<d:sync-token />'
+ . '<cs:getctag />'
+ . $add_props
+ . '</d:prop>'
+ . '</d:propfind>';
+
+ // Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it)
+ $response = $this->request($root_href, 'PROPFIND', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']);
+
+ if (empty($response)) {
+ return false;
+ }
+
+ $folders = [];
+ foreach ($response->getElementsByTagName('response') as $element) {
+ $folder = $this->getFolderPropertiesFromResponse($element);
+
+ // Note: Addressbooks don't have 'type' specified
+ if (($component == 'VCARD' && in_array('addressbook', $folder['resource_type']))
+ || $folder['type'] === $component
+ ) {
+ $folders[] = $folder;
+ }
+ }
+
+ return $folders;
+ }
+
+ /**
+ * Create a DAV object in a folder
+ */
+ public function create($location, $content, $component = 'VEVENT')
+ {
+ $ctype = [
+ 'VEVENT' => 'text/calendar',
+ 'VTODO' => 'text/calendar',
+ 'VCARD' => 'text/vcard',
+ ];
+
+ $headers = ['Content-Type' => $ctype[$component] . '; charset=utf-8'];
+
+ $response = $this->request($location, 'PUT', $content, $headers);
+
+ if ($response !== false) {
+ $etag = $this->responseHeaders['etag'];
+
+ if (preg_match('|^".*"$|', $etag)) {
+ $etag = substr($etag, 1, -1);
+ }
+
+ return $etag;
+ }
+
+ return false;
+ }
+
+ /**
+ * Update a DAV object in a folder
+ */
+ public function update($location, $content, $component = 'VEVENT')
+ {
+ return $this->create($location, $content, $component);
+ }
+
+ /**
+ * Delete a 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')
+ {
+ $queries = [
+ 'VEVENT' => 'calendar-query',
+ 'VTODO' => 'calendar-query',
+ 'VCARD' => 'addressbook-query',
+ ];
+
+ $ns = [
+ 'VEVENT' => 'caldav',
+ 'VTODO' => 'caldav',
+ 'VCARD' => 'carddav',
+ ];
+
+ $filter = '';
+ if ($component != 'VCARD') {
+ $filter = '<c:comp-filter name="VCALENDAR">'
+ . '<c:comp-filter name="' . $component . '" />'
+ . '</c:comp-filter>';
+ }
+
+ $body = '<?xml version="1.0" encoding="utf-8"?>'
+ .' <c:' . $queries[$component] . ' xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:' . $ns[$component]. '">'
+ . '<d:prop>'
+ . '<d:getetag />'
+ . '</d:prop>'
+ . ($filter ? "<c:filter>$filter</c:filter>" : '')
+ . '</c:' . $queries[$component] . '>';
+
+ $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, $component = 'VEVENT', $hrefs = [])
+ {
+ if (empty($hrefs)) {
+ return [];
+ }
+
+ $body = '';
+ foreach ($hrefs as $href) {
+ $body .= '<d:href>' . $href . '</d:href>';
+ }
+
+ $queries = [
+ 'VEVENT' => 'calendar-multiget',
+ 'VTODO' => 'calendar-multiget',
+ 'VCARD' => 'addressbook-multiget',
+ ];
+
+ $ns = [
+ 'VEVENT' => 'caldav',
+ 'VTODO' => 'caldav',
+ 'VCARD' => 'carddav',
+ ];
+
+ $types = [
+ 'VEVENT' => 'calendar-data',
+ 'VTODO' => 'calendar-data',
+ 'VCARD' => 'address-data',
+ ];
+
+ $body = '<?xml version="1.0" encoding="utf-8"?>'
+ .' <c:' . $queries[$component] . ' xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:' . $ns[$component] . '">'
+ . '<d:prop>'
+ . '<d:getetag />'
+ . '<c:' . $types[$component]. ' />'
+ . '</d:prop>'
+ . $body
+ . '</c:' . $queries[$component] . '>';
+
+ $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');
+
+ $doc->formatOutput = true;
+ $doc->preserveWhiteSpace = false;
+
+ if (!$doc->loadXML($body)) {
+ throw new Exception("Failed to parse XML");
+ }
+
+ $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;
+ }
+ }
+
+ $types = [];
+ if ($type_element = $element->getElementsByTagName('resourcetype')->item(0)) {
+ foreach ($type_element->childNodes as $node) {
+ $_type = explode(':', $node->nodeName);
+ $types[] = count($_type) > 1 ? $_type[1] : $_type[0];
+ }
+ }
+
+ return [
+ 'href' => $href,
+ 'name' => $name,
+ 'ctag' => $ctag,
+ 'color' => $color,
+ 'type' => $component,
+ 'resource_type' => $types,
+ ];
+ }
+
+ /**
+ * 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;
+ }
+ else if ($data = $element->getElementsByTagName('address-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 (support both composer-installed and system-installed package)
+ if (!class_exists('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.php b/plugins/libkolab/lib/kolab_storage.php
--- a/plugins/libkolab/lib/kolab_storage.php
+++ b/plugins/libkolab/lib/kolab_storage.php
@@ -944,7 +944,7 @@
* Wrapper for rcube_imap::list_folders_subscribed()
* with support for temporarily subscribed folders
*/
- protected static function _imap_list_subscribed($root, $mbox)
+ protected static function _imap_list_subscribed($root, $mbox, $filter = null)
{
$folders = self::$imap->list_folders_subscribed($root, $mbox);
diff --git a/plugins/libkolab/lib/kolab_storage_cache.php b/plugins/libkolab/lib/kolab_storage_cache.php
--- a/plugins/libkolab/lib/kolab_storage_cache.php
+++ b/plugins/libkolab/lib/kolab_storage_cache.php
@@ -101,8 +101,8 @@
*/
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;
diff --git a/plugins/libkolab/lib/kolab_storage_dataset.php b/plugins/libkolab/lib/kolab_storage_dataset.php
--- a/plugins/libkolab/lib/kolab_storage_dataset.php
+++ b/plugins/libkolab/lib/kolab_storage_dataset.php
@@ -79,18 +79,16 @@
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
@@ -115,8 +113,9 @@
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;
diff --git a/plugins/libkolab/lib/kolab_storage_dav.php b/plugins/libkolab/lib/kolab_storage_dav.php
new file mode 100644
--- /dev/null
+++ b/plugins/libkolab/lib/kolab_storage_dav.php
@@ -0,0 +1,492 @@
+<?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)
+ {
+ $davTypes = [
+ 'event' => 'VEVENT',
+ 'task' => 'VTODO',
+ 'contact' => 'VCARD',
+ ];
+
+ // TODO: This should be cached
+ $folders = $this->dav->discover($davTypes[$type]);
+
+ if (is_array($folders)) {
+ foreach ($folders as $idx => $folder) {
+ // Exclude some special folders
+ if (in_array('schedule-inbox', $folder['resource_type']) || in_array('schedule-outbox', $folder['resource_type'])) {
+ unset($folders[$idx]);
+ continue;
+ }
+
+ $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 $id Folder to access
+ * @param string $type Expected folder type
+ *
+ * @return ?object kolab_storage_folder The folder object
+ */
+ public function get_folder($id, $type = null)
+ {
+ foreach ($this->get_folders($type) as $folder) {
+ if ($folder->id == $id) {
+ return $folder;
+ }
+ }
+ }
+
+ /**
+ * 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 static 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
--- /dev/null
+++ b/plugins/libkolab/lib/kolab_storage_dav_cache.php
@@ -0,0 +1,632 @@
+<?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->folder_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, $this->folder->get_dav_type(), $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, $this->folder->get_dav_type(), $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 folder_index()
+ {
+ // read cache index
+ $sql_result = $this->db->query(
+ "SELECT `uid`, `etag` FROM `{$this->cache_table}` WHERE `folder_id` = ?",
+ $this->folder_id
+ );
+
+ $index = [];
+
+ while ($sql_arr = $this->db->fetch_assoc($sql_result)) {
+ $index[$sql_arr['uid']] = $sql_arr['etag'];
+ }
+
+ return $index;
+ }
+
+ /**
+ * Read a single entry from cache or from server directly
+ *
+ * @param string Object UID
+ * @param string Object type to read
+ * @param string Unused (kept for compat. with the parent class)
+ */
+ public function get($uid, $type = null, $unused = 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
+ * @param string Unused (kept for compat. with the parent class)
+ */
+ public function set($uid, $object, $unused = null)
+ {
+ // 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)
+ * @param string Unused (kept for compat. with the parent class)
+ */
+ public function save($object, $olduid = null, $unused = 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'] = rcube_charset::clean($object['uid']);
+ $sql_data['etag'] = rcube_charset::clean($object['etag']);
+
+ $args = [];
+ $cols = ['folder_id', 'uid', 'etag', '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
+ * @param string Unused (kept for compat. with the parent class)
+ * @param string Unused (kept for compat. with the parent class)
+ */
+ public function move($uid, $target, $unused1 = null, $unused2 = null)
+ {
+ // 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', 'etag', '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,
+ rcube_charset::clean($object['uid']),
+ rcube_charset::clean($object['etag']),
+ $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(rcube_charset::clean($object['uid'])),
+ $this->db->quote(rcube_charset::clean($object['etag'])),
+ $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))) {
+ 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['uid'] = $sql_arr['uid'];
+ $object['etag'] = $sql_arr['etag'];
+ }
+ // 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_contact.php b/plugins/libkolab/lib/kolab_storage_dav_cache_contact.php
new file mode 100644
--- /dev/null
+++ b/plugins/libkolab/lib/kolab_storage_dav_cache_contact.php
@@ -0,0 +1,113 @@
+<?php
+
+/**
+ * Kolab storage cache class for contact objects
+ *
+ * @author Aleksander Machniak <machniak@apcheleia-it.ch>
+ *
+ * Copyright (C) 2013-2022, Apheleia IT AG <contact@apcheleia-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_contact extends kolab_storage_dav_cache
+{
+ protected $extra_cols_max = 255;
+ protected $extra_cols = ['type', 'name', 'firstname', 'surname', 'email'];
+ protected $data_props = ['type', 'name', 'firstname', 'middlename', 'prefix', 'suffix', 'surname', 'email', 'organization', 'member'];
+ protected $fulltext_cols = ['name', 'firstname', 'surname', 'middlename', 'email'];
+
+ /**
+ * 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['type'] = $object['_type'] ?: 'contact';
+
+ // columns for sorting
+ $sql_data['name'] = rcube_charset::clean($object['name'] . $object['prefix']);
+ $sql_data['firstname'] = rcube_charset::clean($object['firstname'] . $object['middlename'] . $object['surname']);
+ $sql_data['surname'] = rcube_charset::clean($object['surname'] . $object['firstname'] . $object['middlename']);
+ $sql_data['email'] = '';
+
+ foreach ($object as $colname => $value) {
+ list($col, $field) = explode(':', $colname);
+ if ($col == 'email' && !empty($value)) {
+ $sql_data['email'] = is_array($value) ? $value[0] : $value;
+ break;
+ }
+ }
+
+ // use organization if name is empty
+ if (empty($sql_data['name']) && !empty($object['organization'])) {
+ $sql_data['name'] = rcube_charset::clean($object['organization']);
+ }
+
+ // make sure some data is not longer that database limit (#5291)
+ foreach ($this->extra_cols as $col) {
+ if (strlen($sql_data[$col]) > $this->extra_cols_max) {
+ $sql_data[$col] = rcube_charset::clean(substr($sql_data[$col], 0, $this->extra_cols_max));
+ }
+ }
+
+ $sql_data['tags'] = ' ' . join(' ', $this->get_tags($object)) . ' '; // pad with spaces for strict/prefix search
+ $sql_data['words'] = ' ' . join(' ', $this->get_words($object)) . ' ';
+
+ return $sql_data;
+ }
+
+ /**
+ * Callback to get words to index for fulltext search
+ *
+ * @return array List of words to save in cache
+ */
+ public function get_words($object)
+ {
+ $data = '';
+
+ foreach ($object as $colname => $value) {
+ list($col, $field) = explode(':', $colname);
+
+ $val = '';
+ if (in_array($col, $this->fulltext_cols)) {
+ $val = is_array($value) ? join(' ', $value) : $value;
+ }
+
+ if (strlen($val)) {
+ $data .= $val . ' ';
+ }
+ }
+
+ return array_unique(rcube_utils::normalize_string($data, true));
+ }
+
+ /**
+ * Callback to get object specific tags to cache
+ *
+ * @return array List of tags to save in cache
+ */
+ public function get_tags($object)
+ {
+ $tags = [];
+
+ if (!empty($object['birthday'])) {
+ $tags[] = 'x-has-birthday';
+ }
+
+ return $tags;
+ }
+}
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
--- /dev/null
+++ b/plugins/libkolab/lib/kolab_storage_dav_cache_event.php
@@ -0,0 +1,146 @@
+<?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 = ['dtstart','dtend'];
+ protected $data_props = ['categories', 'status', 'attendees'];
+ protected $fulltext_cols = ['title', 'description', 'location', 'attendees:name', 'attendees:email', 'categories'];
+
+ /**
+ * 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;
+ }
+ }
+ }
+ }
+
+ $sql_data['tags'] = ' ' . join(' ', $this->get_tags($object)) . ' '; // pad with spaces for strict/prefix search
+ $sql_data['words'] = ' ' . join(' ', $this->get_words($object)) . ' ';
+
+ return $sql_data;
+ }
+
+ /**
+ * Callback to get words to index for fulltext search
+ *
+ * @return array List of words to save in cache
+ */
+ public function get_words($object = [])
+ {
+ $data = '';
+
+ foreach ($this->fulltext_cols as $colname) {
+ list($col, $field) = explode(':', $colname);
+
+ if ($field) {
+ $a = [];
+ foreach ((array) $object[$col] as $attr) {
+ $a[] = $attr[$field];
+ }
+ $val = join(' ', $a);
+ }
+ else {
+ $val = is_array($object[$col]) ? join(' ', $object[$col]) : $object[$col];
+ }
+
+ if (strlen($val))
+ $data .= $val . ' ';
+ }
+
+ $words = rcube_utils::normalize_string($data, true);
+
+ // collect words from recurrence exceptions
+ if (is_array($object['exceptions'])) {
+ foreach ($object['exceptions'] as $exception) {
+ $words = array_merge($words, $this->get_words($exception));
+ }
+ }
+
+ return array_unique($words);
+ }
+
+ /**
+ * Callback to get object specific tags to cache
+ *
+ * @return array List of tags to save in cache
+ */
+ public function get_tags($object)
+ {
+ $tags = [];
+
+ if (!empty($object['valarms'])) {
+ $tags[] = 'x-has-alarms';
+ }
+
+ // create tags reflecting participant status
+ if (is_array($object['attendees'])) {
+ foreach ($object['attendees'] as $attendee) {
+ if (!empty($attendee['email']) && !empty($attendee['status']))
+ $tags[] = 'x-partstat:' . $attendee['email'] . ':' . strtolower($attendee['status']);
+ }
+ }
+
+ // collect tags from recurrence exceptions
+ if (is_array($object['exceptions'])) {
+ foreach ($object['exceptions'] as $exception) {
+ $tags = array_merge($tags, $this->get_tags($exception));
+ }
+ }
+
+ if (!empty($object['status'])) {
+ $tags[] = 'x-status:' . strtolower($object['status']);
+ }
+
+ return array_unique($tags);
+ }
+}
diff --git a/plugins/libkolab/lib/kolab_storage_dav_folder.php b/plugins/libkolab/lib/kolab_storage_dav_folder.php
new file mode 100644
--- /dev/null
+++ b/plugins/libkolab/lib/kolab_storage_dav_folder.php
@@ -0,0 +1,587 @@
+<?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'];
+ $this->id = md5($this->href);
+ $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'];
+ }
+
+ public function get_folder_info()
+ {
+ return []; // todo ?
+ }
+
+ /**
+ * 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}";
+
+ $host_path = parse_url($host, PHP_URL_PATH);
+ if ($host_path && strpos($path, $host_path) === 0) {
+ $path = substr($path, strlen($host_path));
+ }
+
+ $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)) {
+ $method = $uid ? 'update' : 'create';
+ $dav_type = $this->get_dav_type();
+ $result = $this->dav->{$method}($this->object_location($object['uid']), $content, $dav_type);
+
+ // Note: $result can be NULL if the request was successful, but ETag wasn't returned
+ if ($result !== false) {
+ // insert/update object in the cache
+ $object['etag'] = $result;
+ $this->cache->save($object, $uid);
+ $result = true;
+ }
+ }
+
+ 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)
+ * @param string Unused (kept for compat. with the parent class)
+ *
+ * @return mixed Hash array representing the Kolab object, a kolab_format instance or false if not found
+ */
+ public function read_object($uid, $type = null, $folder = null)
+ {
+ if (!$this->valid) {
+ return false;
+ }
+
+ $href = $this->object_location($uid);
+ $objects = $this->dav->getData($this->href, $this->get_dav_type(), [$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];
+ }
+ else if ($this->type == 'contact') {
+ if (stripos($object['data'], 'BEGIN:VCARD') !== 0) {
+ return false;
+ }
+
+ $vcard = new rcube_vcard($object['data'], RCUBE_CHARSET, false);
+
+ if (!empty($vcard->displayname) || !empty($vcard->surname) || !empty($vcard->firstname) || !empty($vcard->email)) {
+ $result = $vcard->get_assoc();
+ }
+ else {
+ return false;
+ }
+ }
+
+ $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();
+ if (!empty($object['exceptions'])) {
+ $object['recurrence']['EXCEPTIONS'] = $object['exceptions'];
+ }
+
+ $result = $ical->export([$object]);
+ }
+ else if ($this->type == 'contact') {
+ // copy values into vcard object
+ $vcard = new rcube_vcard('', RCUBE_CHARSET, false, ['uid' => 'UID']);
+
+ $vcard->set('groups', null);
+
+ foreach ($object as $key => $values) {
+ list($field, $section) = rcube_utils::explode(':', $key);
+
+ // avoid casting DateTime objects to array
+ if (is_object($values) && is_a($values, 'DateTime')) {
+ $values = [$values];
+ }
+
+ foreach ((array) $values as $value) {
+ if (isset($value)) {
+ $vcard->set($field, $value, $section);
+ }
+ }
+ }
+
+ $result = $vcard->export(false);
+ }
+
+ if ($result) {
+ // The content must be UTF-8, otherwise if we try to fetch the object
+ // from server XML parsing would fail.
+ $result = rcube_charset::clean($result);
+ }
+
+ 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
--- a/plugins/libkolab/lib/kolab_storage_folder.php
+++ b/plugins/libkolab/lib/kolab_storage_folder.php
@@ -45,6 +45,7 @@
*
* @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)
{
@@ -596,7 +597,7 @@
*/
public function save(&$object, $type = null, $uid = null)
{
- if (!$this->valid && empty($object)) {
+ if (!$this->valid || empty($object)) {
return false;
}
diff --git a/plugins/libkolab/libkolab.php b/plugins/libkolab/libkolab.php
--- a/plugins/libkolab/libkolab.php
+++ b/plugins/libkolab/libkolab.php
@@ -235,8 +235,10 @@
$key = md5(serialize($http_config));
if (!($request = self::$http_requests[$key])) {
- // load HTTP_Request2
- require_once 'HTTP/Request2.php';
+ // load HTTP_Request2 (support both composer-installed and system-installed package)
+ if (!class_exists('HTTP_Request2')) {
+ require_once 'HTTP/Request2.php';
+ }
try {
$request = new HTTP_Request2();

File Metadata

Mime Type
text/plain
Expires
Mon, Mar 30, 11:28 AM (3 d, 6 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18806247
Default Alt Text
D3908.1774870103.diff (207 KB)

Event Timeline