From f46ed48258f06863460e8018b1d2aba019a28d23 Mon Sep 17 00:00:00 2001 From: Aleksander Machniak Date: Sun, 16 Oct 2022 10:54:01 +0200 Subject: [PATCH] CalDAV and CardDAV drivers Summary: CardDAV driver for kolab_addressbook, CalDAV driver for calendar Reviewers: #roundcube_kolab_plugins_developers Subscribers: #roundcube_kolab_plugins_developers Differential Revision: https://git.kolab.org/D3908 --- .../drivers/caldav/caldav_calendar.php | 30 +- .../lib/rcube_carddav_contacts.php | 1285 +++++++++++++++++ 2 files changed, 1300 insertions(+), 15 deletions(-) create mode 100644 plugins/kolab_addressbook/lib/rcube_carddav_contacts.php 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 @@ -372,8 +372,8 @@ class caldav_calendar extends kolab_storage_dav_folder }); // 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; @@ -443,8 +443,8 @@ class caldav_calendar extends kolab_storage_dav_folder } // 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); @@ -461,9 +461,9 @@ class caldav_calendar extends kolab_storage_dav_folder } // 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)]; @@ -485,8 +485,8 @@ class caldav_calendar extends kolab_storage_dav_folder } // 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']); @@ -501,9 +501,9 @@ class caldav_calendar extends kolab_storage_dav_folder } 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)]; @@ -737,9 +737,9 @@ class caldav_calendar extends kolab_storage_dav_folder // 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(); 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 @@ + + * @author Aleksander Machniak + * + * Copyright (C) 2011-2022, Kolab Systems AG + * + * 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 . + * + * @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; + } +}