Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F256971
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
72 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/plugins/calendar/drivers/caldav/caldav_calendar.php b/plugins/calendar/drivers/caldav/caldav_calendar.php
index 89452def..92cb46ec 100644
--- a/plugins/calendar/drivers/caldav/caldav_calendar.php
+++ b/plugins/calendar/drivers/caldav/caldav_calendar.php
@@ -1,904 +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);
+ // $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']);
+ // $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;
- }
+ // 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']);
+ // $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;
- }
+ // 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']);
- }
+ // 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/kolab_addressbook/lib/rcube_carddav_contacts.php b/plugins/kolab_addressbook/lib/rcube_carddav_contacts.php
new file mode 100644
index 00000000..59e112ee
--- /dev/null
+++ b/plugins/kolab_addressbook/lib/rcube_carddav_contacts.php
@@ -0,0 +1,1285 @@
+<?php
+
+/**
+ * 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_dav wrapper from libkolab
+ *
+ * @author Thomas Bruederli <bruederli@kolabsys.com>
+ * @author Aleksander Machniak <machniak@apheleia-it.chm>
+ *
+ * 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
+ * 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/>.
+ *
+ * @see rcube_addressbook
+ */
+class rcube_carddav_contacts extends rcube_addressbook
+{
+ public $primary_key = 'ID';
+ public $rights = 'lrs';
+ public $readonly = true;
+ 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 = ['birthday', 'anniversary'];
+
+ public $fulltext_cols = ['name', 'firstname', 'surname', 'middlename', 'email'];
+
+ private $gid;
+ private $storage;
+ private $dataset;
+ private $sortindex;
+ private $contacts;
+ private $distlists;
+ private $groupmembers;
+ private $filter;
+ private $result;
+ private $namespace;
+ private $action;
+
+ // list of fields used for searching in "All fields" mode
+ private $search_fields = [
+ 'name',
+ 'firstname',
+ 'surname',
+ 'middlename',
+ 'prefix',
+ 'suffix',
+ 'nickname',
+ 'jobtitle',
+ 'organization',
+ 'department',
+ 'email',
+ 'phone',
+ 'address',
+// 'profession',
+ 'manager',
+ 'assistant',
+ 'spouse',
+ 'children',
+ 'notes',
+ ];
+
+
+ /**
+ * Object constructor
+ */
+ public function __construct($dav_folder = null)
+ {
+ $this->storage = $dav_folder;
+ $this->ready = !empty($this->storage);
+
+ // Set readonly and rights flags according to folder permissions
+ if ($this->ready) {
+ if ($this->storage->get_owner() == $_SESSION['username']) {
+ $this->readonly = false;
+ $this->rights = 'lrswikxtea';
+ }
+ else {
+ $rights = $this->storage->get_myrights();
+ if ($rights && !PEAR::isError($rights)) {
+ $this->rights = $rights;
+ if (strpos($rights, 'i') !== false && strpos($rights, 't') !== false) {
+ $this->readonly = false;
+ }
+ }
+ }
+ }
+
+ $this->action = rcube::get_instance()->action;
+ }
+
+ /**
+ * Getter for the address book name to be displayed
+ *
+ * @return string Name of this address book
+ */
+ public function get_name()
+ {
+ return $this->storage->get_name();
+ }
+
+ /**
+ * Wrapper for kolab_storage_folder::get_foldername()
+ */
+ public function get_foldername()
+ {
+ return $this->storage->get_foldername();
+ }
+
+ /**
+ * Getter for the folder name
+ *
+ * @return string Name of the folder
+ */
+ public function get_realname()
+ {
+ return $this->get_name();
+ }
+
+ /**
+ * Getter for the name of the namespace to which the IMAP folder belongs
+ *
+ * @return string Name of the namespace (personal, other, shared)
+ */
+ public function get_namespace()
+ {
+ if ($this->namespace === null && $this->ready) {
+ $this->namespace = $this->storage->get_namespace();
+ }
+
+ return $this->namespace;
+ }
+
+ /**
+ * Getter for parent folder path
+ *
+ * @return string Full path to parent folder
+ */
+ public function get_parent()
+ {
+ return $this->storage->get_parent();
+ }
+
+ /**
+ * Check subscription status of this folder
+ *
+ * @return boolean True if subscribed, false if not
+ */
+ public function is_subscribed()
+ {
+ return true;
+ }
+
+ /**
+ * Compose an URL for CardDAV access to this address book (if configured)
+ */
+ public function get_carddav_url()
+ {
+/*
+ $rcmail = rcmail::get_instance();
+ if ($template = $rcmail->config->get('kolab_addressbook_carddav_url', null)) {
+ return strtr($template, [
+ '%h' => $_SERVER['HTTP_HOST'],
+ '%u' => urlencode($rcmail->get_user_name()),
+ '%i' => urlencode($this->storage->get_uid()),
+ '%n' => urlencode($this->imap_folder),
+ ]);
+ }
+*/
+ return false;
+ }
+
+ /**
+ * Setter for the current group
+ */
+ public function set_group($gid)
+ {
+ $this->gid = $gid;
+ }
+
+ /**
+ * Save a search string for future listings
+ *
+ * @param mixed Search params to use in listing method, obtained by get_search_set()
+ */
+ public function set_search_set($filter)
+ {
+ $this->filter = $filter;
+ }
+
+ /**
+ * Getter for saved search properties
+ *
+ * @return mixed Search properties used by this class
+ */
+ public function get_search_set()
+ {
+ return $this->filter;
+ }
+
+ /**
+ * Reset saved results and search parameters
+ */
+ public function reset()
+ {
+ $this->result = null;
+ $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
+ *
+ * @param string Optional search string to match group name
+ * @param int Search mode. Sum of self::SEARCH_*
+ *
+ * @return array Indexed list of contact groups, each a hash array
+ */
+ function list_groups($search = null, $mode = 0)
+ {
+ $this->_fetch_groups();
+ $groups = [];
+
+ foreach ((array)$this->distlists as $group) {
+ if (!$search || strstr(mb_strtolower($group['name']), mb_strtolower($search))) {
+ $groups[$group['ID']] = ['ID' => $group['ID'], 'name' => $group['name']];
+ }
+ }
+
+ // sort groups by name
+ uasort($groups, function($a, $b) { return strcoll($a['name'], $b['name']); });
+
+ return array_values($groups);
+ }
+
+ /**
+ * List the current set of contact records
+ *
+ * @param array List of cols to show
+ * @param int Only return this number of records, use negative values for tail
+ * @param bool True to skip the count query (select only)
+ *
+ * @return array Indexed list of contact records, each a hash array
+ */
+ public function list_records($cols = null, $subset = 0, $nocount = false)
+ {
+ $this->result = new rcube_result_set(0, ($this->list_page-1) * $this->page_size);
+
+ $fetch_all = false;
+ $fast_mode = !empty($cols) && is_array($cols);
+
+ // list member of the selected group
+ if ($this->gid) {
+ $this->_fetch_groups();
+
+ $this->sortindex = [];
+ $this->contacts = [];
+ $local_sortindex = [];
+ $uids = [];
+
+ // get members with email specified
+ foreach ((array)$this->distlists[$this->gid]['member'] as $member) {
+ // skip member that don't match the search filter
+ if (!empty($this->filter['ids']) && array_search($member['ID'], $this->filter['ids']) === false) {
+ continue;
+ }
+
+ if (!empty($member['uid'])) {
+ $uids[] = $member['uid'];
+ }
+ else if (!empty($member['email'])) {
+ $this->contacts[$member['ID']] = $member;
+ $local_sortindex[$member['ID']] = $this->_sort_string($member);
+ $fetch_all = true;
+ }
+ }
+
+ // get members by UID
+ if (!empty($uids)) {
+ $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([$this, 'id2uid'], $this->filter['ids']);
+ $this->_fetch_contacts($query = [['uid', '=', $uids]], count($ids), $fast_mode);
+ }
+ }
+ else {
+ $this->_fetch_contacts($query = 'contact', true, $fast_mode);
+ }
+
+ if ($fetch_all) {
+ // sort results (index only)
+ asort($this->sortindex, SORT_LOCALE_STRING);
+ $ids = array_keys($this->sortindex);
+
+ // fill contact data into the current result set
+ $this->result->count = count($ids);
+ $start_row = $subset < 0 ? $this->result->first + $this->page_size + $subset : $this->result->first;
+ $last_row = min($subset != 0 ? $start_row + abs($subset) : $this->result->first + $this->page_size, $this->result->count);
+
+ for ($i = $start_row; $i < $last_row; $i++) {
+ if (array_key_exists($i, $ids)) {
+ $idx = $ids[$i];
+ $this->result->add($this->contacts[$idx] ?: $this->_to_rcube_contact($this->dataset[$idx]));
+ }
+ }
+ }
+ else if (!empty($this->dataset)) {
+ // get all records count, skip the query if possible
+ if (!isset($query) || count($this->dataset) < $this->page_size) {
+ $this->result->count = count($this->dataset) + $this->page_size * ($this->list_page - 1);
+ }
+ else {
+ $this->result->count = $this->storage->count($query);
+ }
+
+ $start_row = $subset < 0 ? $this->page_size + $subset : 0;
+ $last_row = min($subset != 0 ? $start_row + abs($subset) : $this->page_size, $this->result->count);
+
+ for ($i = $start_row; $i < $last_row; $i++) {
+ $this->result->add($this->_to_rcube_contact($this->dataset[$i]));
+ }
+ }
+
+ return $this->result;
+ }
+
+ /**
+ * Search records
+ *
+ * @param mixed $fields The field name of array of field names to search in
+ * @param mixed $value Search value (or array of values when $fields is array)
+ * @param int $mode Matching mode:
+ * 0 - partial (*abc*),
+ * 1 - strict (=),
+ * 2 - prefix (abc*)
+ * 4 - include groups (if supported)
+ * @param bool $select True if results are requested, False if count only
+ * @param bool $nocount True to skip the count query (select only)
+ * @param array $required List of fields that cannot be empty
+ *
+ * @return rcube_result_set List of contact records and 'count' value
+ */
+ public function search($fields, $value, $mode = 0, $select = true, $nocount = false, $required = [])
+ {
+ // search by ID
+ if ($fields == $this->primary_key) {
+ $ids = !is_array($value) ? explode(',', $value) : $value;
+ $result = new rcube_result_set();
+
+ foreach ($ids as $id) {
+ if ($rec = $this->get_record($id, true)) {
+ $result->add($rec);
+ $result->count++;
+ }
+ }
+ return $result;
+ }
+ else if ($fields == '*') {
+ $fields = $this->search_fields;
+ }
+
+ if (!is_array($fields)) {
+ $fields = [$fields];
+ }
+ if (!is_array($required) && !empty($required)) {
+ $required = [$required];
+ }
+
+ // advanced search
+ if (is_array($value)) {
+ $advanced = true;
+ $value = array_map('mb_strtolower', $value);
+ }
+ else {
+ $value = mb_strtolower($value);
+ }
+
+ $scount = count($fields);
+ // build key name regexp
+ $regexp = '/^(' . implode('|', $fields) . ')(?:.*)$/';
+
+ // pass query to storage if only indexed cols are involved
+ // NOTE: this is only some rough pre-filtering but probably includes false positives
+ $squery = $this->_search_query($fields, $value, $mode);
+
+ // add magic selector to select contacts with birthday dates only
+ if (in_array('birthday', $required)) {
+ $squery[] = ['tags', '=', 'x-has-birthday'];
+ }
+
+ $squery[] = ['type', '=', 'contact'];
+
+ // get all/matching records
+ $this->_fetch_contacts($squery);
+
+ // save searching conditions
+ $this->filter = ['fields' => $fields, 'value' => $value, 'mode' => $mode, 'ids' => []];
+
+ // search by iterating over all records in dataset
+ foreach ($this->dataset as $record) {
+ $contact = $this->_to_rcube_contact($record);
+ $id = $contact['ID'];
+
+ // check if current contact has required values, otherwise skip it
+ if ($required) {
+ foreach ($required as $f) {
+ // required field might be 'email', but contact might contain 'email:home'
+ if (!($v = rcube_addressbook::get_col_values($f, $contact, true)) || empty($v)) {
+ continue 2;
+ }
+ }
+ }
+
+ $found = [];
+ $contents = '';
+
+ foreach (preg_grep($regexp, array_keys($contact)) as $col) {
+ $pos = strpos($col, ':');
+ $colname = $pos ? substr($col, 0, $pos) : $col;
+
+ foreach ((array)$contact[$col] as $val) {
+ if ($advanced) {
+ $found[$colname] = $this->compare_search_value($colname, $val, $value[array_search($colname, $fields)], $mode);
+ }
+ else {
+ $contents .= ' ' . join(' ', (array)$val);
+ }
+ }
+ }
+
+ // compare matches
+ if (($advanced && count($found) >= $scount) ||
+ (!$advanced && rcube_utils::words_match(mb_strtolower($contents), $value))) {
+ $this->filter['ids'][] = $id;
+ }
+ }
+
+ // dummy result with contacts count
+ if (!$select) {
+ return new rcube_result_set(count($this->filter['ids']), ($this->list_page-1) * $this->page_size);
+ }
+
+ // list records (now limited by $this->filter)
+ return $this->list_records();
+ }
+
+ /**
+ * Refresh saved search results after data has changed
+ */
+ public function refresh_search()
+ {
+ if ($this->filter) {
+ $this->search($this->filter['fields'], $this->filter['value'], $this->filter['mode']);
+ }
+
+ return $this->get_search_set();
+ }
+
+ /**
+ * Count number of available contacts in database
+ *
+ * @return rcube_result_set Result set with values for 'count' and 'first'
+ */
+ public function count()
+ {
+ if ($this->gid) {
+ $this->_fetch_groups();
+ $count = count($this->distlists[$this->gid]['member']);
+ }
+ else if (is_array($this->filter['ids'])) {
+ $count = count($this->filter['ids']);
+ }
+ else {
+ $count = $this->storage->count('contact');
+ }
+
+ return new rcube_result_set($count, ($this->list_page-1) * $this->page_size);
+ }
+
+ /**
+ * Return the last result set
+ *
+ * @return rcube_result_set Current result set or NULL if nothing selected yet
+ */
+ public function get_result()
+ {
+ return $this->result;
+ }
+
+ /**
+ * Get a specific contact record
+ *
+ * @param mixed Record identifier(s)
+ * @param bool True to return record as associative array, otherwise a result set is returned
+ *
+ * @return mixed Result object with all record fields or False if not found
+ */
+ public function get_record($id, $assoc = false)
+ {
+ $rec = null;
+ $uid = $this->id2uid($id);
+ $rev = rcube_utils::get_input_value('_rev', rcube_utils::INPUT_GPC);
+
+ if (strpos($uid, 'mailto:') === 0) {
+ $this->_fetch_groups(true);
+ $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');
+ if ($plugin && ($object = $plugin->get_revision($id, kolab_storage::id_encode($this->imap_folder), $rev))) {
+ $rec = $this->_to_rcube_contact($object);
+ $rec['rev'] = $rev;
+ }
+ $this->readonly = true; // set source to read-only
+ }
+*/
+ else if ($object = $this->storage->get_object($uid)) {
+ $rec = $this->_to_rcube_contact($object);
+ }
+
+ if ($rec) {
+ $this->result = new rcube_result_set(1);
+ $this->result->add($rec);
+ return $assoc ? $rec : $this->result;
+ }
+
+ return false;
+ }
+
+ /**
+ * Get group assignments of a specific contact record
+ *
+ * @param mixed Record identifier
+ *
+ * @return array List of assigned groups as ID=>Name pairs
+ */
+ public function get_record_groups($id)
+ {
+ $out = [];
+ $this->_fetch_groups();
+
+ if (!empty($this->groupmembers[$id])) {
+ foreach ((array) $this->groupmembers[$id] as $gid) {
+ if (!empty($this->distlists[$gid])) {
+ $group = $this->distlists[$gid];
+ $out[$gid] = $group['name'];
+ }
+ }
+ }
+
+ return $out;
+ }
+
+ /**
+ * Create a new contact record
+ *
+ * @param array Associative array with save data
+ * Keys: Field name with optional section in the form FIELD:SECTION
+ * Values: Field value. Can be either a string or an array of strings for multiple values
+ * @param bool True to check for duplicates first
+ *
+ * @return mixed The created record ID on success, False on error
+ */
+ public function insert($save_data, $check=false)
+ {
+ if (!is_array($save_data)) {
+ return false;
+ }
+
+ $insert_id = $existing = false;
+
+ // check for existing records by e-mail comparison
+ if ($check) {
+ foreach ($this->get_col_values('email', $save_data, true) as $email) {
+ if (($res = $this->search('email', $email, true, false)) && $res->count) {
+ $existing = true;
+ break;
+ }
+ }
+ }
+
+ if (!$existing) {
+ // 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->storage->save($object, 'contact');
+
+ if (!$saved) {
+ rcube::raise_error([
+ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__,
+ 'message' => "Error saving contact object to CardDAV server"
+ ],
+ true, false);
+ }
+ else {
+ $insert_id = $object['uid'];
+ }
+ }
+
+ return $insert_id;
+ }
+
+ /**
+ * Update a specific contact record
+ *
+ * @param mixed Record identifier
+ * @param array Associative array with save data
+ * Keys: Field name with optional section in the form FIELD:SECTION
+ * Values: Field value. Can be either a string or an array of strings for multiple values
+ *
+ * @return bool True on success, False on error
+ */
+ public function update($id, $save_data)
+ {
+ $updated = false;
+ if ($old = $this->storage->get_object($this->id2uid($id))) {
+ $object = $this->_from_rcube_contact($save_data, $old);
+
+ if (!$this->storage->save($object, 'contact', $old['uid'])) {
+ rcube::raise_error([
+ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__,
+ 'message' => "Error saving contact object to CardDAV server"
+ ],
+ true, false
+ );
+ }
+ else {
+ $updated = true;
+
+ // TODO: update data in groups this contact is member of
+ }
+ }
+
+ return $updated;
+ }
+
+ /**
+ * Mark one or more contact records as deleted
+ *
+ * @param array Record identifiers
+ * @param bool Remove record(s) irreversible (mark as deleted otherwise)
+ *
+ * @return int Number of records deleted
+ */
+ public function delete($ids, $force = true)
+ {
+ $this->_fetch_groups();
+
+ if (!is_array($ids)) {
+ $ids = explode(',', $ids);
+ }
+
+ $count = 0;
+ foreach ($ids as $id) {
+ if ($uid = $this->id2uid($id)) {
+ $is_mailto = strpos($uid, 'mailto:') === 0;
+ $deleted = $is_mailto || $this->storage->delete($uid, $force);
+
+ if (!$deleted) {
+ rcube::raise_error([
+ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__,
+ 'message' => "Error deleting a contact object $uid from the CardDAV server"
+ ],
+ true, false
+ );
+ }
+ else {
+ // remove from distribution lists
+ foreach ((array) $this->groupmembers[$id] as $gid) {
+ if (!$is_mailto || $gid == $this->gid) {
+ $this->remove_from_group($gid, $id);
+ }
+ }
+
+ // clear internal cache
+ unset($this->groupmembers[$id]);
+ $count++;
+ }
+ }
+ }
+
+ return $count;
+ }
+
+ /**
+ * Undelete one or more contact records.
+ * Only possible just after delete (see 2nd argument of delete() method).
+ *
+ * @param array Record identifiers
+ *
+ * @return int Number of records restored
+ */
+ public function undelete($ids)
+ {
+ if (!is_array($ids)) {
+ $ids = explode(',', $ids);
+ }
+
+ $count = 0;
+ foreach ($ids as $id) {
+ $uid = $this->id2uid($id);
+ if ($this->storage->undelete($uid)) {
+ $count++;
+ }
+ else {
+ rcube::raise_error([
+ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__,
+ 'message' => "Error undeleting a contact object $uid from the CardDav server"
+ ],
+ true, false
+ );
+ }
+ }
+
+ return $count;
+ }
+
+ /**
+ * Remove all records from the database
+ *
+ * @param bool $with_groups Remove also groups
+ */
+ public function delete_all($with_groups = false)
+ {
+ if ($this->storage->delete_all()) {
+ $this->contacts = [];
+ $this->sortindex = [];
+ $this->dataset = null;
+ $this->result = null;
+ }
+ }
+
+ /**
+ * Close connection to source
+ * Called on script shutdown
+ */
+ public function close()
+ {
+ // NOP
+ }
+
+ /**
+ * Create a contact group with the given name
+ *
+ * @param string The group name
+ *
+ * @return mixed False on error, array with record props in success
+ */
+ function create_group($name)
+ {
+ $this->_fetch_groups();
+ $result = false;
+
+ $list = [
+ 'name' => $name,
+ 'member' => [],
+ ];
+ $saved = $this->storage->save($list, 'distribution-list');
+
+ if (!$saved) {
+ rcube::raise_error([
+ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__,
+ 'message' => "Error saving distribution-list object to CardDAV server"
+ ],
+ true, false
+ );
+ return false;
+ }
+ else {
+ $id = $this->uid2id($list['uid']);
+ $this->distlists[$id] = $list;
+ $result = ['id' => $id, 'name' => $name];
+ }
+
+ return $result;
+ }
+
+ /**
+ * Delete the given group and all linked group members
+ *
+ * @param string Group identifier
+ *
+ * @return bool True on success, false if no data was changed
+ */
+ function delete_group($gid)
+ {
+ $this->_fetch_groups();
+ $result = false;
+
+ if ($list = $this->distlists[$gid]) {
+ $deleted = $this->storage->delete($list['uid']);
+ }
+
+ if (!$deleted) {
+ rcube::raise_error([
+ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__,
+ 'message' => "Error deleting distribution-list object from the CardDAV server"
+ ],
+ true, false
+ );
+ }
+ else {
+ $result = true;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Rename a specific contact group
+ *
+ * @param string Group identifier
+ * @param string New name to set for this group
+ * @param string New group identifier (if changed, otherwise don't set)
+ *
+ * @return bool New name on success, false if no data was changed
+ */
+ function rename_group($gid, $newname, &$newid)
+ {
+ $this->_fetch_groups();
+ $list = $this->distlists[$gid];
+
+ if ($newname != $list['name']) {
+ $list['name'] = $newname;
+ $saved = $this->storage->save($list, 'distribution-list', $list['uid']);
+ }
+
+ if (!$saved) {
+ rcube::raise_error([
+ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__,
+ 'message' => "Error saving distribution-list object to CardDAV server"
+ ],
+ true, false
+ );
+ return false;
+ }
+
+ return $newname;
+ }
+
+ /**
+ * Add the given contact records the a certain group
+ *
+ * @param string Group identifier
+ * @param array List of contact identifiers to be added
+ * @return int Number of contacts added
+ */
+ function add_to_group($gid, $ids)
+ {
+ if (!is_array($ids)) {
+ $ids = explode(',', $ids);
+ }
+
+ $this->_fetch_groups(true);
+
+ $list = $this->distlists[$gid];
+ $added = 0;
+ $uids = [];
+ $exists = [];
+
+ foreach ((array)$list['member'] as $member) {
+ $exists[] = $member['ID'];
+ }
+
+ // substract existing assignments from list
+ $ids = array_unique(array_diff($ids, $exists));
+
+ // add mailto: members
+ foreach ($ids as $contact_id) {
+ $uid = $this->id2uid($contact_id);
+ if (strpos($uid, 'mailto:') === 0 && ($contact = $this->contacts[$contact_id])) {
+ $list['member'][] = [
+ 'email' => $contact['email'],
+ 'name' => $contact['name'],
+ ];
+ $this->groupmembers[$contact_id][] = $gid;
+ $added++;
+ }
+ else {
+ $uids[$uid] = $contact_id;
+ }
+ }
+
+ // add members with UID
+ if (!empty($uids)) {
+ foreach ($uids as $uid => $contact_id) {
+ $list['member'][] = ['uid' => $uid];
+ $this->groupmembers[$contact_id][] = $gid;
+ $added++;
+ }
+ }
+
+ if ($added) {
+ $saved = $this->storage->save($list, 'distribution-list', $list['uid']);
+ }
+ else {
+ $saved = true;
+ }
+
+ if (!$saved) {
+ rcube::raise_error([
+ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__,
+ 'message' => "Error saving distribution-list to CardDAV server"
+ ],
+ true, false
+ );
+
+ $added = false;
+ $this->set_error(self::ERROR_SAVING, 'errorsaving');
+ }
+ else {
+ $this->distlists[$gid] = $list;
+ }
+
+ return $added;
+ }
+
+ /**
+ * Remove the given contact records from a certain group
+ *
+ * @param string Group identifier
+ * @param array List of contact identifiers to be removed
+ *
+ * @return bool
+ */
+ function remove_from_group($gid, $ids)
+ {
+ if (!is_array($ids)) {
+ $ids = explode(',', $ids);
+ }
+
+ $this->_fetch_groups();
+ if (!($list = $this->distlists[$gid])) {
+ return false;
+ }
+
+ $new_member = [];
+ foreach ((array) $list['member'] as $member) {
+ if (!in_array($member['ID'], $ids)) {
+ $new_member[] = $member;
+ }
+ }
+
+ // write distribution list back to server
+ $list['member'] = $new_member;
+ $saved = $this->storage->save($list, 'distribution-list', $list['uid']);
+
+ if (!$saved) {
+ rcube::raise_error([
+ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__,
+ 'message' => "Error saving distribution-list object to CardDAV server"
+ ],
+ true, false
+ );
+ }
+ else {
+ // remove group assigments in local cache
+ foreach ($ids as $id) {
+ $j = array_search($gid, $this->groupmembers[$id]);
+ unset($this->groupmembers[$id][$j]);
+ }
+ $this->distlists[$gid] = $list;
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Check the given data before saving.
+ * If input not valid, the message to display can be fetched using get_error()
+ *
+ * @param array Associative array with contact data to save
+ * @param bool Attempt to fix/complete data automatically
+ *
+ * @return bool True if input is valid, False if not.
+ */
+ public function validate(&$save_data, $autofix = false)
+ {
+ // validate e-mail addresses
+ $valid = parent::validate($save_data);
+
+ // require at least one e-mail address if there's no name
+ // (syntax check is already done)
+ if ($valid) {
+ if (!strlen($save_data['name'])
+ && !strlen($save_data['organization'])
+ && !array_filter($this->get_col_values('email', $save_data, true))
+ ) {
+ $this->set_error('warning', 'kolab_addressbook.noemailnamewarning');
+ $valid = false;
+ }
+ }
+
+ return $valid;
+ }
+
+ /**
+ * Query storage layer and store records in private member var
+ */
+ 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->storage->set_order_and_limit($this->_sort_columns(), $size, ($this->list_page-1) * $this->page_size);
+ }
+
+ $this->sortindex = [];
+ $this->dataset = $this->storage->select($query, $fast_mode);
+
+ foreach ($this->dataset as $idx => $record) {
+ $contact = $this->_to_rcube_contact($record);
+ $this->sortindex[$idx] = $this->_sort_string($contact);
+ }
+ }
+ }
+
+ /**
+ * Extract a string for sorting from the given contact record
+ */
+ private function _sort_string($rec)
+ {
+ $str = '';
+
+ switch ($this->sort_col) {
+ case 'name':
+ $str = $rec['name'] . $rec['prefix'];
+ case 'firstname':
+ $str .= $rec['firstname'] . $rec['middlename'] . $rec['surname'];
+ break;
+
+ case 'surname':
+ $str = $rec['surname'] . $rec['firstname'] . $rec['middlename'];
+ break;
+
+ default:
+ $str = $rec[$this->sort_col];
+ break;
+ }
+
+ $str .= is_array($rec['email']) ? $rec['email'][0] : $rec['email'];
+
+ return mb_strtolower($str);
+ }
+
+ /**
+ * Return the cache table columns to order by
+ */
+ private function _sort_columns()
+ {
+ $sortcols = [];
+
+ switch ($this->sort_col) {
+ case 'name':
+ $sortcols[] = 'name';
+
+ case 'firstname':
+ $sortcols[] = 'firstname';
+ break;
+
+ case 'surname':
+ $sortcols[] = 'surname';
+ break;
+ }
+
+ $sortcols[] = 'email';
+ return $sortcols;
+ }
+
+ /**
+ * Read distribution-lists AKA groups from server
+ */
+ private function _fetch_groups($with_contacts = false)
+ {
+ return; // TODO
+
+ if (!isset($this->distlists)) {
+ $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']);
+ $record['member'][$i]['ID'] = $mid;
+ $record['member'][$i]['readonly'] = empty($member['uid']);
+ $this->groupmembers[$mid][] = $record['ID'];
+
+ if ($with_contacts && empty($member['uid'])) {
+ $this->contacts[$mid] = $record['member'][$i];
+ }
+ }
+ $this->distlists[$record['ID']] = $record;
+ }
+ }
+ }
+
+ /**
+ * Encode object UID into a safe identifier
+ */
+ public function uid2id($uid)
+ {
+ return rtrim(strtr(base64_encode($uid), '+/', '-_'), '=');
+ }
+
+ /**
+ * Convert Roundcube object identifier back into the original UID
+ */
+ public function id2uid($id)
+ {
+ return base64_decode(str_pad(strtr($id, '-_', '+/'), strlen($id) % 4, '=', STR_PAD_RIGHT));
+ }
+
+ /**
+ * Build SQL query for fulltext matches
+ */
+ private function _search_query($fields, $value, $mode)
+ {
+ $query = [];
+ $cols = [];
+
+ $cols = array_intersect($fields, $this->fulltext_cols);
+
+ if (count($cols)) {
+ if ($mode & rcube_addressbook::SEARCH_STRICT) {
+ $prefix = '^'; $suffix = '$';
+ }
+ else if ($mode & rcube_addressbook::SEARCH_PREFIX) {
+ $prefix = '^'; $suffix = '';
+ }
+ else {
+ $prefix = ''; $suffix = '';
+ }
+
+ $search_string = is_array($value) ? join(' ', $value) : $value;
+ foreach (rcube_utils::normalize_string($search_string, true) as $word) {
+ $query[] = ['words', 'LIKE', $prefix . $word . $suffix];
+ }
+ }
+
+ return $query;
+ }
+
+ /**
+ * Map fields from internal Kolab_Format to Roundcube contact format
+ */
+ private function _to_rcube_contact($record)
+ {
+ $record['ID'] = $this->uid2id($record['uid']);
+
+ // remove empty fields
+ $record = array_filter($record);
+
+ // Set _type for proper icon on the list
+ $record['_type'] = 'person';
+
+ return $record;
+ }
+
+ /**
+ * Map fields from Roundcube format to internal kolab_format_contact properties
+ */
+ private function _from_rcube_contact($contact, $old = [])
+ {
+ if (empty($contact['uid']) && !empty($contact['ID'])) {
+ $contact['uid'] = $this->id2uid($contact['ID']);
+ }
+ else if (empty($contact['uid']) && !empty($old['uid'])) {
+ $contact['uid'] = $old['uid'];
+ }
+ else if (empty($contact['uid'])) {
+ $rcube = rcube::get_instance();
+ $contact['uid'] = strtoupper(md5(time() . uniqid(rand())) . '-' . substr(md5($rcube->user->get_username()), 0, 16));
+ }
+
+ // When importing contacts 'vcard' data might be added, we don't need it (Bug #1711)
+ unset($contact['vcard']);
+
+ return $contact;
+ }
+}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Tue, Jun 10, 8:50 AM (1 d, 12 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
197024
Default Alt Text
(72 KB)
Attached To
Mode
R14 roundcubemail-plugins-kolab
Attached
Detach File
Event Timeline
Log In to Comment