1<?php
2
3/**
4 +-----------------------------------------------------------------------+
5 | This file is part of the Roundcube Webmail client                     |
6 |                                                                       |
7 | Copyright (C) The Roundcube Dev Team                                  |
8 |                                                                       |
9 | Licensed under the GNU General Public License version 3 or            |
10 | any later version with exceptions for skins & plugins.                |
11 | See the README file for a full license statement.                     |
12 |                                                                       |
13 | PURPOSE:                                                              |
14 |   Interface to the local address book database                        |
15 +-----------------------------------------------------------------------+
16 | Author: Thomas Bruederli <roundcube@gmail.com>                        |
17 +-----------------------------------------------------------------------+
18*/
19
20/**
21 * Model class for the local address book database
22 *
23 * @package    Framework
24 * @subpackage Addressbook
25 */
26class rcube_contacts extends rcube_addressbook
27{
28    // protected for backward compat. with some plugins
29    protected $db_name         = 'contacts';
30    protected $db_groups       = 'contactgroups';
31    protected $db_groupmembers = 'contactgroupmembers';
32    protected $vcard_fieldmap  = [];
33
34    /**
35     * Store database connection.
36     *
37     * @var rcube_db
38     */
39    protected $db      = null;
40    protected $user_id = 0;
41    protected $filter  = null;
42    protected $result  = null;
43    protected $cache;
44    protected $table_cols    = ['name', 'email', 'firstname', 'surname'];
45    protected $fulltext_cols = ['name', 'firstname', 'surname', 'middlename', 'nickname',
46        'jobtitle', 'organization', 'department', 'maidenname', 'email', 'phone',
47        'address', 'street', 'locality', 'zipcode', 'region', 'country', 'website', 'im', 'notes'];
48
49    // public properties
50    public $primary_key = 'contact_id';
51    public $name;
52    public $readonly  = false;
53    public $groups    = true;
54    public $undelete  = true;
55    public $list_page = 1;
56    public $page_size = 10;
57    public $group_id  = 0;
58    public $ready     = false;
59    public $coltypes  = ['name', 'firstname', 'surname', 'middlename', 'prefix', 'suffix', 'nickname',
60        'jobtitle', 'organization', 'department', 'assistant', 'manager',
61        'gender', 'maidenname', 'spouse', 'email', 'phone', 'address',
62        'birthday', 'anniversary', 'website', 'im', 'notes', 'photo'];
63    public $date_cols = ['birthday', 'anniversary'];
64
65    const SEPARATOR = ',';
66
67
68    /**
69     * Object constructor
70     *
71     * @param object $dbconn Instance of the rcube_db class
72     * @param int    $user   User-ID
73     */
74    function __construct($dbconn, $user)
75    {
76        $this->db      = $dbconn;
77        $this->user_id = $user;
78        $this->ready   = $this->db && !$this->db->is_error();
79    }
80
81    /**
82     * Returns addressbook name
83     *
84     * @return string
85     */
86     function get_name()
87     {
88        return $this->name;
89     }
90
91    /**
92     * Save a search string for future listings
93     *
94     * @param string $filter SQL params to use in listing method
95     */
96    function set_search_set($filter)
97    {
98        $this->filter = $filter;
99        $this->cache  = null;
100    }
101
102    /**
103     * Getter for saved search properties
104     *
105     * @return mixed Search properties used by this class
106     */
107    function get_search_set()
108    {
109        return $this->filter;
110    }
111
112    /**
113     * Setter for the current group
114     * (empty, has to be re-implemented by extending class)
115     */
116    function set_group($gid)
117    {
118        $this->group_id = $gid;
119        $this->cache    = null;
120    }
121
122    /**
123     * Reset all saved results and search parameters
124     */
125    function reset()
126    {
127        $this->result = null;
128        $this->filter = null;
129        $this->cache  = null;
130    }
131
132    /**
133     * List all active contact groups of this source
134     *
135     * @param string $search Search string to match group name
136     * @param int    $mode   Matching mode. Sum of rcube_addressbook::SEARCH_*
137     *
138     * @return array Indexed list of contact groups, each a hash array
139     */
140    function list_groups($search = null, $mode = 0)
141    {
142        $results = [];
143
144        if (!$this->groups) {
145            return $results;
146        }
147
148        $sql_filter = '';
149
150        if ($search) {
151            if ($mode & rcube_addressbook::SEARCH_STRICT) {
152                $sql_filter = $this->db->ilike('name', $search);
153            }
154            else if ($mode & rcube_addressbook::SEARCH_PREFIX) {
155                $sql_filter = $this->db->ilike('name', $search . '%');
156            }
157            else {
158                $sql_filter = $this->db->ilike('name', '%' . $search . '%');
159            }
160
161            $sql_filter = " AND $sql_filter";
162        }
163
164        $sql_result = $this->db->query(
165            "SELECT * FROM " . $this->db->table_name($this->db_groups, true)
166            . " WHERE `del` <> 1 AND `user_id` = ?" . $sql_filter
167            . " ORDER BY `name`",
168            $this->user_id
169        );
170
171        while ($sql_result && ($sql_arr = $this->db->fetch_assoc($sql_result))) {
172            $sql_arr['ID'] = $sql_arr['contactgroup_id'];
173            $results[]     = $sql_arr;
174        }
175
176        return $results;
177    }
178
179    /**
180     * Get group properties such as name and email address(es)
181     *
182     * @param string $group_id Group identifier
183     *
184     * @return ?array Group properties as hash array, null in case of error.
185     */
186    function get_group($group_id)
187    {
188        $sql_result = $this->db->query(
189            "SELECT * FROM " . $this->db->table_name($this->db_groups, true)
190            . " WHERE `del` <> 1 AND `contactgroup_id` = ? AND `user_id` = ?",
191            $group_id, $this->user_id
192        );
193
194        if ($sql_result && ($sql_arr = $this->db->fetch_assoc($sql_result))) {
195            $sql_arr['ID'] = $sql_arr['contactgroup_id'];
196            return $sql_arr;
197        }
198
199        return null;
200    }
201
202    /**
203     * List the current set of contact records
204     *
205     * @param array List of cols to show, Null means all
206     * @param int   Only return this number of records, use negative values for tail
207     * @param bool  True to skip the count query (select only)
208     *
209     * @return array Indexed list of contact records, each a hash array
210     */
211    function list_records($cols = null, $subset = 0, $nocount = false)
212    {
213        if ($nocount || $this->list_page <= 1) {
214            // create dummy result, we don't need a count now
215            $this->result = new rcube_result_set();
216        } else {
217            // count all records
218            $this->result = $this->count();
219        }
220
221        $start_row = $subset < 0 ? $this->result->first + $this->page_size + $subset : $this->result->first;
222        $length    = $subset != 0 ? abs($subset) : $this->page_size;
223        $join      = '';
224
225        if ($this->group_id) {
226            $join = " LEFT JOIN " . $this->db->table_name($this->db_groupmembers, true) . " AS m".
227                " ON (m.`contact_id` = c.`".$this->primary_key."`)";
228        }
229
230        $order_col  = in_array($this->sort_col, $this->table_cols) ? $this->sort_col : 'name';
231        $order_cols = ["c.`$order_col`"];
232
233        if ($order_col == 'firstname') {
234            $order_cols[] = 'c.`surname`';
235        }
236        else if ($order_col == 'surname') {
237            $order_cols[] = 'c.`firstname`';
238        }
239        if ($order_col != 'name') {
240            $order_cols[] = 'c.`name`';
241        }
242        $order_cols[] = 'c.`email`';
243
244        $sql_result = $this->db->limitquery(
245            "SELECT * FROM " . $this->db->table_name($this->db_name, true) . " AS c" .
246            $join .
247            " WHERE c.`del` <> 1" .
248                " AND c.`user_id` = ?" .
249                ($this->group_id ? " AND m.`contactgroup_id` = ?" : "").
250                ($this->filter ? " AND ".$this->filter : "") .
251            " ORDER BY ". $this->db->concat($order_cols) . " " . $this->sort_order,
252            $start_row,
253            $length,
254            $this->user_id,
255            $this->group_id
256        );
257
258        // determine whether we have to parse the vcard or if only db cols are requested
259        $read_vcard = !$cols || count(array_intersect($cols, $this->table_cols)) < count($cols);
260
261        while ($sql_result && ($sql_arr = $this->db->fetch_assoc($sql_result))) {
262            $sql_arr['ID'] = $sql_arr[$this->primary_key];
263
264            if ($read_vcard) {
265                $sql_arr = $this->convert_db_data($sql_arr);
266            }
267            else {
268                $sql_arr['email'] = $sql_arr['email'] ? explode(self::SEPARATOR, $sql_arr['email']) : [];
269                $sql_arr['email'] = array_map('trim', $sql_arr['email']);
270            }
271
272            $this->result->add($sql_arr);
273        }
274
275        $cnt = count($this->result->records);
276
277        // update counter
278        if ($nocount) {
279            $this->result->count = $cnt;
280        }
281        else if ($this->list_page <= 1) {
282            if ($cnt < $this->page_size && $subset == 0) {
283                $this->result->count = $cnt;
284            }
285            else if (isset($this->cache['count'])) {
286                $this->result->count = $this->cache['count'];
287            }
288            else {
289                $this->result->count = $this->_count();
290            }
291        }
292
293        return $this->result;
294    }
295
296    /**
297     * Search contacts
298     *
299     * @param mixed $fields   The field name or array of field names to search in
300     * @param mixed $value    Search value (or array of values when $fields is array)
301     * @param int   $mode     Search mode. Sum of rcube_addressbook::SEARCH_*
302     * @param bool  $select   True if results are requested, False if count only
303     * @param bool  $nocount  True to skip the count query (select only)
304     * @param array $required List of fields that cannot be empty
305     *
306     * @return rcube_result_set Contact records and 'count' value
307     */
308    function search($fields, $value, $mode = 0, $select = true, $nocount = false, $required = [])
309    {
310        if (!is_array($required) && !empty($required)) {
311            $required = [$required];
312        }
313
314        $where = $post_search = [];
315        $mode  = intval($mode);
316
317        // direct ID search
318        if ($fields == 'ID' || $fields == $this->primary_key) {
319            $ids     = !is_array($value) ? explode(self::SEPARATOR, $value) : $value;
320            $ids     = $this->db->array2list($ids, 'integer');
321            $where[] = 'c.' . $this->primary_key.' IN ('.$ids.')';
322        }
323        else if (is_array($value)) {
324            foreach ((array) $fields as $idx => $col) {
325                $val = $value[$idx];
326
327                if (!strlen($val)) {
328                    continue;
329                }
330
331                // table column
332                if (in_array($col, $this->table_cols)) {
333                    $where[] = $this->fulltext_sql_where($val, $mode, $col);
334                }
335                // vCard field
336                else {
337                    if (in_array($col, $this->fulltext_cols)) {
338                        $where[] = $this->fulltext_sql_where($val, $mode, 'words');
339                    }
340                    $post_search[$col] = mb_strtolower($val);
341                }
342            }
343        }
344        // fulltext search in all fields
345        else if ($fields == '*') {
346            $where[] = $this->fulltext_sql_where($value, $mode, 'words');
347        }
348        else {
349            // require each word in to be present in one of the fields
350            $words = ($mode & rcube_addressbook::SEARCH_STRICT) ? [$value] : rcube_utils::tokenize_string($value, 1);
351            foreach ($words as $word) {
352                $groups = [];
353                foreach ((array) $fields as $idx => $col) {
354                    $groups[] = $this->fulltext_sql_where($word, $mode, $col);
355                }
356                $where[] = '(' . implode(' OR ', $groups) . ')';
357            }
358        }
359
360        foreach (array_intersect($required, $this->table_cols) as $col) {
361            $where[] = $this->db->quote_identifier($col).' <> '.$this->db->quote('');
362        }
363        $required = array_diff($required, $this->table_cols);
364
365        if (!empty($where)) {
366            // use AND operator for advanced searches
367            $where = implode(' AND ', $where);
368        }
369
370        // Post-searching in vCard data fields
371        // we will search in all records and then build a where clause for their IDs
372        if (!empty($post_search) || !empty($required)) {
373            $ids = [0];
374            // build key name regexp
375            $regexp = '/^(' . implode('|', array_keys($post_search)) . ')(?:.*)$/';
376            // use initial WHERE clause, to limit records number if possible
377            if (!empty($where)) {
378                $this->set_search_set($where);
379            }
380
381            // count result pages
382            $cnt   = $this->count()->count;
383            $pages = ceil($cnt / $this->page_size);
384            $scnt  = !empty($post_search) ? count($post_search) : 0;
385
386            // get (paged) result
387            for ($i=0; $i<$pages; $i++) {
388                $this->list_records(null, $i, true);
389                while ($row = $this->result->next()) {
390                    $id    = $row[$this->primary_key];
391                    $found = [];
392                    if (!empty($post_search)) {
393                        foreach (preg_grep($regexp, array_keys($row)) as $col) {
394                            $pos     = strpos($col, ':');
395                            $colname = $pos ? substr($col, 0, $pos) : $col;
396                            $search  = $post_search[$colname];
397                            foreach ((array) $row[$col] as $value) {
398                                if ($this->compare_search_value($colname, $value, $search, $mode)) {
399                                    $found[$colname] = true;
400                                    break;
401                                }
402                            }
403                        }
404                    }
405                    // check if required fields are present
406                    if (!empty($required)) {
407                        foreach ($required as $req) {
408                            $hit = false;
409                            foreach (array_keys($row) as $c) {
410                                if ($c === $req || strpos($c, $req.':') === 0) {
411                                    if ((is_string($row[$c]) && strlen($row[$c])) || !empty($row[$c])) {
412                                        $hit = true;
413                                        break;
414                                    }
415                                }
416                            }
417                            if (!$hit) {
418                                continue 2;
419                            }
420                        }
421                    }
422                    // all fields match
423                    if (count($found) >= $scnt) {
424                        $ids[] = $id;
425                    }
426                }
427            }
428
429            // build WHERE clause
430            $ids = $this->db->array2list($ids, 'integer');
431            $where = 'c.`' . $this->primary_key.'` IN ('.$ids.')';
432            // reset counter
433            unset($this->cache['count']);
434
435            // when we know we have an empty result
436            if ($ids == '0') {
437                $this->set_search_set($where);
438                return ($this->result = new rcube_result_set(0, 0));
439            }
440        }
441
442        if (!empty($where)) {
443            $this->set_search_set($where);
444            if ($select) {
445                $this->list_records(null, 0, $nocount);
446            }
447            else {
448                $this->result = $this->count();
449            }
450        }
451
452        return $this->result;
453    }
454
455    /**
456     * Helper method to compose SQL where statements for fulltext searching
457     */
458    protected function fulltext_sql_where($value, $mode, $col = 'words', $bool = 'AND')
459    {
460        $AS    = $col == 'words' ? ' ' : self::SEPARATOR;
461        $words = $col == 'words' ? rcube_utils::normalize_string($value, true, 1) : [$value];
462
463        $where = [];
464        foreach ($words as $word) {
465            if ($mode & rcube_addressbook::SEARCH_STRICT) {
466                $where[] = '(' . $this->db->ilike($col, $word)
467                    . ' OR ' . $this->db->ilike($col, $word . $AS . '%')
468                    . ' OR ' . $this->db->ilike($col, '%' . $AS . $word . $AS . '%')
469                    . ' OR ' . $this->db->ilike($col, '%' . $AS . $word) . ')';
470            }
471            else if ($mode & rcube_addressbook::SEARCH_PREFIX) {
472                $where[] = '(' . $this->db->ilike($col, $word . '%')
473                    . ' OR ' . $this->db->ilike($col, '%' . $AS . $word . '%') . ')';
474            }
475            else {
476                $where[] = $this->db->ilike($col, '%' . $word . '%');
477            }
478        }
479
480        return count($where) ? '(' . implode(" $bool ", $where) . ')' : '';
481    }
482
483    /**
484     * Count number of available contacts in database
485     *
486     * @return rcube_result_set Result object
487     */
488    function count()
489    {
490        $count = isset($this->cache['count']) ? $this->cache['count'] : $this->_count();
491
492        return new rcube_result_set($count, ($this->list_page-1) * $this->page_size);
493    }
494
495    /**
496     * Count number of available contacts in database
497     *
498     * @return int Contacts count
499     */
500    protected function _count()
501    {
502        $join = null;
503
504        if ($this->group_id) {
505            $join = " LEFT JOIN " . $this->db->table_name($this->db_groupmembers, true) . " AS m".
506                " ON (m.`contact_id` = c.`".$this->primary_key."`)";
507        }
508
509        // count contacts for this user
510        $sql_result = $this->db->query(
511            "SELECT COUNT(c.`contact_id`) AS cnt".
512            " FROM " . $this->db->table_name($this->db_name, true) . " AS c".
513                $join.
514            " WHERE c.`del` <> 1".
515            " AND c.`user_id` = ?".
516            ($this->group_id ? " AND m.`contactgroup_id` = ?" : "").
517            ($this->filter ? " AND (".$this->filter.")" : ""),
518            $this->user_id,
519            $this->group_id
520        );
521
522        $sql_arr = $this->db->fetch_assoc($sql_result);
523
524        $this->cache['count'] = !empty($sql_arr) ? (int) $sql_arr['cnt'] : 0;
525
526        return $this->cache['count'];
527    }
528
529    /**
530     * Return the last result set
531     *
532     * @return mixed Result array or NULL if nothing selected yet
533     */
534    function get_result()
535    {
536        return $this->result;
537    }
538
539    /**
540     * Get a specific contact record
541     *
542     * @param mixed $id    Record identifier(s)
543     * @param bool  $assoc Enables returning associative array
544     *
545     * @return rcube_result_set|array Result object with all record fields
546     */
547    function get_record($id, $assoc = false)
548    {
549        // return cached result
550        if ($this->result && ($first = $this->result->first()) && $first[$this->primary_key] == $id) {
551            return $assoc ? $first : $this->result;
552        }
553
554        $this->db->query(
555            "SELECT * FROM " . $this->db->table_name($this->db_name, true).
556            " WHERE `contact_id` = ?".
557                " AND `user_id` = ?".
558                " AND `del` <> 1",
559            $id,
560            $this->user_id
561        );
562
563        $this->result = null;
564
565        if ($sql_arr = $this->db->fetch_assoc()) {
566            $record = $this->convert_db_data($sql_arr);
567            $this->result = new rcube_result_set(1);
568            $this->result->add($record);
569        }
570
571        return $assoc && !empty($record) ? $record : $this->result;
572    }
573
574    /**
575     * Get group assignments of a specific contact record
576     *
577     * @param mixed $id Record identifier
578     *
579     * @return array List of assigned groups, indexed by a group ID
580     */
581    function get_record_groups($id)
582    {
583        $results = [];
584
585        if (!$this->groups) {
586            return $results;
587        }
588
589        $sql_result = $this->db->query(
590            "SELECT cgm.`contactgroup_id`, cg.`name` "
591            . " FROM " . $this->db->table_name($this->db_groupmembers, true) . " AS cgm"
592            . " LEFT JOIN " . $this->db->table_name($this->db_groups, true) . " AS cg"
593                . " ON (cgm.`contactgroup_id` = cg.`contactgroup_id` AND cg.`del` <> 1)"
594            . " WHERE cgm.`contact_id` = ?",
595            $id
596        );
597
598        while ($sql_result && ($sql_arr = $this->db->fetch_assoc($sql_result))) {
599            $results[$sql_arr['contactgroup_id']] = $sql_arr['name'];
600        }
601
602        return $results;
603    }
604
605    /**
606     * Check the given data before saving.
607     * If input not valid, the message to display can be fetched using get_error()
608     *
609     * @param array &$save_data Associative array with data to save
610     * @param bool  $autofix    Try to fix/complete record automatically
611     *
612     * @return bool True if input is valid, False if not.
613     */
614    public function validate(&$save_data, $autofix = false)
615    {
616        // validate e-mail addresses
617        $valid = parent::validate($save_data, $autofix);
618
619        // require at least some name or email
620        if ($valid) {
621            $name = (isset($save_data['firstname']) ? $save_data['firstname'] : '')
622                . (isset($save_data['surname']) ? $save_data['surname'] : '')
623                . (isset($save_data['name']) ? $save_data['name'] : '');
624
625            if (!strlen($name) && !count(array_filter($this->get_col_values('email', $save_data, true)))) {
626                $this->set_error(self::ERROR_VALIDATE, 'nonamewarning');
627                $valid = false;
628            }
629        }
630
631        return $valid;
632    }
633
634    /**
635     * Create a new contact record
636     *
637     * @param array $save_data Associative array with save data
638     * @param bool  $check     Enables validity checks
639     *
640     * @return int|bool The created record ID on success, False on error
641     */
642    function insert($save_data, $check = false)
643    {
644        if (!is_array($save_data)) {
645            return false;
646        }
647
648        $insert_id = $existing = false;
649
650        if ($check) {
651            foreach ($save_data as $col => $values) {
652                if (strpos($col, 'email') === 0) {
653                    foreach ((array)$values as $email) {
654                        if ($existing = $this->search('email', $email, false, false))
655                            break 2;
656                    }
657                }
658            }
659        }
660
661        $save_data     = $this->convert_save_data($save_data);
662        $a_insert_cols = $a_insert_values = [];
663
664        foreach ($save_data as $col => $value) {
665            $a_insert_cols[]   = $this->db->quote_identifier($col);
666            $a_insert_values[] = $this->db->quote($value);
667        }
668
669        if ((empty($existing) || empty($existing->count)) && !empty($a_insert_cols)) {
670            $this->db->query(
671                "INSERT INTO " . $this->db->table_name($this->db_name, true)
672                . " (`user_id`, `changed`, `del`, " . implode(', ', $a_insert_cols) . ")"
673                . " VALUES (" . intval($this->user_id) . ", " . $this->db->now() . ", 0, " . implode(', ', $a_insert_values) . ")"
674            );
675
676            $insert_id = $this->db->insert_id($this->db_name);
677        }
678
679        $this->cache = null;
680
681        return $insert_id;
682    }
683
684    /**
685     * Update a specific contact record
686     *
687     * @param mixed $id        Record identifier
688     * @param array $save_cols Associative array with save data
689     *
690     * @return bool True on success, False on error
691     */
692    function update($id, $save_cols)
693    {
694        $updated   = false;
695        $write_sql = [];
696        $record    = $this->get_record($id, true);
697        $save_cols = $this->convert_save_data($save_cols, $record);
698
699        foreach ($save_cols as $col => $value) {
700            $write_sql[] = sprintf("%s=%s", $this->db->quote_identifier($col), $this->db->quote($value));
701        }
702
703        if (!empty($write_sql)) {
704            $this->db->query(
705                "UPDATE " . $this->db->table_name($this->db_name, true)
706                . " SET `changed` = " . $this->db->now() . ", " . implode(', ', $write_sql)
707                . " WHERE `contact_id` = ?"
708                    . " AND `user_id` = ?"
709                    . " AND `del` <> 1",
710                $id,
711                $this->user_id
712            );
713
714            $updated = $this->db->affected_rows();
715            $this->result = null;  // clear current result (from get_record())
716        }
717
718        return !empty($updated);
719    }
720
721    /**
722     * Convert data stored in the database into output format
723     */
724    private function convert_db_data($sql_arr)
725    {
726        $record = [
727            'ID' => $sql_arr[$this->primary_key]
728        ];
729
730        if ($sql_arr['vcard']) {
731            unset($sql_arr['email']);
732            $vcard = new rcube_vcard($sql_arr['vcard'], RCUBE_CHARSET, false, $this->vcard_fieldmap);
733            $record += $vcard->get_assoc() + $sql_arr;
734        }
735        else {
736            $record += $sql_arr;
737            $record['email'] = explode(self::SEPARATOR, $record['email']);
738            $record['email'] = array_map('trim', $record['email']);
739        }
740
741        return $record;
742    }
743
744    /**
745     * Convert input data for storing in the database
746     */
747    private function convert_save_data($save_data, $record = [])
748    {
749        $out   = [];
750        $words = '';
751
752        if (!empty($record['vcard'])) {
753            $vcard = $record['vcard'];
754        }
755        else if (!empty($save_data['vcard'])) {
756            $vcard = $save_data['vcard'];
757        }
758        else {
759            $vcard = '';
760        }
761
762        // copy values into vcard object
763        $vcard = new rcube_vcard($vcard, RCUBE_CHARSET, false, $this->vcard_fieldmap);
764        $vcard->reset();
765
766        // don't store groups in vCard (#1490277)
767        $vcard->set('groups', null);
768        unset($save_data['groups']);
769
770        foreach ($save_data as $key => $values) {
771            list($field, $section) = rcube_utils::explode(':', $key);
772
773            $fulltext = in_array($field, $this->fulltext_cols);
774
775            // avoid casting DateTime objects to array
776            if (is_object($values) && is_a($values, 'DateTime')) {
777                $values = [$values];
778            }
779            foreach ((array) $values as $value) {
780                if (isset($value)) {
781                    $vcard->set($field, $value, $section);
782                }
783                if ($fulltext && is_array($value)) {
784                    $words .= ' ' . rcube_utils::normalize_string(implode(' ', $value));
785                }
786                else if ($fulltext && strlen($value) >= 3) {
787                    $words .= ' ' . rcube_utils::normalize_string($value);
788                }
789            }
790        }
791
792        $out['vcard'] = $vcard->export(false);
793
794        foreach ($this->table_cols as $col) {
795            $key = $col;
796            if (!isset($save_data[$key])) {
797                $key .= ':home';
798            }
799            if (isset($save_data[$key])) {
800                if (is_array($save_data[$key])) {
801                    $out[$col] = implode(self::SEPARATOR, $save_data[$key]);
802                }
803                else {
804                    $out[$col] = $save_data[$key];
805                }
806            }
807        }
808
809        // save all e-mails in the database column
810        if (!empty($vcard->email)) {
811            $out['email'] = implode(self::SEPARATOR, $vcard->email);
812        } else {
813            $out['email'] = isset($save_data['email']) ? $save_data['email'] : '';
814        }
815
816        // join words for fulltext search
817        $out['words'] = implode(' ', array_unique(explode(' ', $words)));
818
819        return $out;
820    }
821
822    /**
823     * Mark one or more contact records as deleted
824     *
825     * @param array $ids   Record identifiers
826     * @param bool  $force Remove record(s) irreversible (unsupported)
827     *
828     * @return int|false Number of removed records, False on failure
829     */
830    function delete($ids, $force = true)
831    {
832        if (!is_array($ids)) {
833            $ids = explode(self::SEPARATOR, $ids);
834        }
835
836        $ids = $this->db->array2list($ids, 'integer');
837
838        // flag record as deleted (always)
839        $this->db->query(
840            "UPDATE " . $this->db->table_name($this->db_name, true).
841            " SET `del` = 1, `changed` = ".$this->db->now().
842            " WHERE `user_id` = ?".
843                " AND `contact_id` IN ($ids)",
844            $this->user_id
845        );
846
847        $this->cache = null;
848
849        return $this->db->affected_rows();
850    }
851
852    /**
853     * Undelete one or more contact records
854     *
855     * @param array $ids Record identifiers
856     *
857     * @return int Number of undeleted contact records
858     */
859    function undelete($ids)
860    {
861        if (!is_array($ids)) {
862            $ids = explode(self::SEPARATOR, $ids);
863        }
864
865        $ids = $this->db->array2list($ids, 'integer');
866
867        // clear deleted flag
868        $this->db->query(
869            "UPDATE " . $this->db->table_name($this->db_name, true).
870            " SET `del` = 0, `changed` = ".$this->db->now().
871            " WHERE `user_id` = ?".
872                " AND `contact_id` IN ($ids)",
873            $this->user_id
874        );
875
876        $this->cache = null;
877
878        return $this->db->affected_rows();
879    }
880
881    /**
882     * Remove all records from the database
883     *
884     * @param bool $with_groups Remove also groups
885     *
886     * @return int Number of removed records
887     */
888    function delete_all($with_groups = false)
889    {
890        $this->cache = null;
891
892        $now = $this->db->now();
893
894        $this->db->query("UPDATE " . $this->db->table_name($this->db_name, true)
895            . " SET `del` = 1, `changed` = $now"
896            . " WHERE `user_id` = ?", $this->user_id);
897
898        $count = $this->db->affected_rows();
899
900        if ($with_groups) {
901            $this->db->query("UPDATE " . $this->db->table_name($this->db_groups, true)
902                . " SET `del` = 1, `changed` = $now"
903                . " WHERE `user_id` = ?", $this->user_id);
904
905            $count += $this->db->affected_rows();
906        }
907
908        return $count;
909    }
910
911    /**
912     * Create a contact group with the given name
913     *
914     * @param string $name The group name
915     *
916     * @return array|false False on error, array with record props in success
917     */
918    function create_group($name)
919    {
920        $result = false;
921
922        // make sure we have a unique name
923        $name = $this->unique_groupname($name);
924
925        $this->db->query(
926            "INSERT INTO " . $this->db->table_name($this->db_groups, true).
927            " (`user_id`, `changed`, `name`)".
928            " VALUES (".intval($this->user_id).", ".$this->db->now().", ".$this->db->quote($name).")"
929        );
930
931        if ($insert_id = $this->db->insert_id($this->db_groups)) {
932            $result = ['id' => $insert_id, 'name' => $name];
933        }
934
935        return $result;
936    }
937
938    /**
939     * Delete the given group (and all linked group members)
940     *
941     * @param string $gid Group identifier
942     *
943     * @return bool True on success, false if no data was changed
944     */
945    function delete_group($gid)
946    {
947        // flag group record as deleted
948        $sql_result = $this->db->query(
949            "UPDATE " . $this->db->table_name($this->db_groups, true)
950            . " SET `del` = 1, `changed` = " . $this->db->now()
951            . " WHERE `contactgroup_id` = ?"
952                . " AND `user_id` = ?",
953            $gid, $this->user_id
954        );
955
956        $this->cache = null;
957
958        return $this->db->affected_rows($sql_result) > 0;
959    }
960
961    /**
962     * Rename a specific contact group
963     *
964     * @param string $gid     Group identifier
965     * @param string $name    New name to set for this group
966     * @param string $new_gid (not used)
967     *
968     * @return string|false New name on success, false if no data was changed
969     */
970    function rename_group($gid, $name, &$new_gid)
971    {
972        // make sure we have a unique name
973        $name = $this->unique_groupname($name);
974
975        $sql_result = $this->db->query(
976            "UPDATE " . $this->db->table_name($this->db_groups, true).
977            " SET `name` = ?, `changed` = ".$this->db->now().
978            " WHERE `contactgroup_id` = ?".
979                " AND `user_id` = ?",
980            $name, $gid, $this->user_id
981        );
982
983        return $this->db->affected_rows($sql_result) ? $name : false;
984    }
985
986    /**
987     * Add the given contact records the a certain group
988     *
989     * @param string       Group identifier
990     * @param array|string List of contact identifiers to be added
991     *
992     * @return int Number of contacts added
993     */
994    function add_to_group($group_id, $ids)
995    {
996        if (!is_array($ids)) {
997            $ids = explode(self::SEPARATOR, $ids);
998        }
999
1000        $added  = 0;
1001        $exists = [];
1002
1003        // get existing assignments ...
1004        $sql_result = $this->db->query(
1005            "SELECT `contact_id` FROM " . $this->db->table_name($this->db_groupmembers, true).
1006            " WHERE `contactgroup_id` = ?".
1007                " AND `contact_id` IN (".$this->db->array2list($ids, 'integer').")",
1008            $group_id
1009        );
1010
1011        while ($sql_result && ($sql_arr = $this->db->fetch_assoc($sql_result))) {
1012            $exists[] = $sql_arr['contact_id'];
1013        }
1014
1015        // ... and remove them from the list
1016        $ids = array_diff($ids, $exists);
1017
1018        foreach ($ids as $contact_id) {
1019            $this->db->query(
1020                "INSERT INTO " . $this->db->table_name($this->db_groupmembers, true).
1021                " (`contactgroup_id`, `contact_id`, `created`)".
1022                " VALUES (?, ?, ".$this->db->now().")",
1023                $group_id,
1024                $contact_id
1025            );
1026
1027            if ($error = $this->db->is_error()) {
1028                $this->set_error(self::ERROR_SAVING, $error);
1029            }
1030            else {
1031                $added++;
1032            }
1033        }
1034
1035        return $added;
1036    }
1037
1038    /**
1039     * Remove the given contact records from a certain group
1040     *
1041     * @param string       $group_id Group identifier
1042     * @param array|string $ids      List of contact identifiers to be removed
1043     *
1044     * @return int Number of deleted group members
1045     */
1046    function remove_from_group($group_id, $ids)
1047    {
1048        if (!is_array($ids)) {
1049            $ids = explode(self::SEPARATOR, $ids);
1050        }
1051
1052        $ids = $this->db->array2list($ids, 'integer');
1053
1054        $sql_result = $this->db->query(
1055            "DELETE FROM " . $this->db->table_name($this->db_groupmembers, true).
1056            " WHERE `contactgroup_id` = ?".
1057                " AND `contact_id` IN ($ids)",
1058            $group_id
1059        );
1060
1061        return $this->db->affected_rows($sql_result);
1062    }
1063
1064    /**
1065     * Check for existing groups with the same name
1066     *
1067     * @param string $name Name to check
1068     *
1069     * @return string A group name which is unique for the current use
1070     */
1071    private function unique_groupname($name)
1072    {
1073        $checkname = $name;
1074        $num       = 2;
1075        $hit       = false;
1076
1077        do {
1078            $sql_result = $this->db->query(
1079                "SELECT 1 FROM " . $this->db->table_name($this->db_groups, true).
1080                " WHERE `del` <> 1".
1081                    " AND `user_id` = ?".
1082                    " AND `name` = ?",
1083                $this->user_id,
1084                $checkname);
1085
1086            // append number to make name unique
1087            if ($hit = $this->db->fetch_array($sql_result)) {
1088                $checkname = $name . ' ' . $num++;
1089            }
1090        }
1091        while ($hit);
1092
1093        return $checkname;
1094    }
1095}
1096