Page MenuHomePhorge

No OneTemporary

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

Mime Type
text/x-diff
Expires
Tue, Jun 10, 8:50 AM (1 d, 17 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
197024
Default Alt Text
(72 KB)

Event Timeline