Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117652860
D3908.1774870103.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
207 KB
Referenced Files
None
Subscribers
None
D3908.1774870103.diff
View Options
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"], ' ');
+ 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"], ' ');
+ }
}
$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
Details
Attached
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)
Attached To
Mode
D3908: CalDAV and CardDAV drivers
Attached
Detach File
Event Timeline