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 * Abstract skeleton of an address book/repository
22 *
23 * @package    Framework
24 * @subpackage Addressbook
25 */
26abstract class rcube_addressbook
27{
28    // constants for error reporting
29    const ERROR_READ_ONLY     = 1;
30    const ERROR_NO_CONNECTION = 2;
31    const ERROR_VALIDATE      = 3;
32    const ERROR_SAVING        = 4;
33    const ERROR_SEARCH        = 5;
34
35    // search modes
36    const SEARCH_ALL    = 0;
37    const SEARCH_STRICT = 1;
38    const SEARCH_PREFIX = 2;
39    const SEARCH_GROUPS = 4;
40
41    // contact types, note: some of these are used as addressbook source identifiers
42    const TYPE_CONTACT        = 0;
43    const TYPE_RECIPIENT      = 1;
44    const TYPE_TRUSTED_SENDER = 2;
45    const TYPE_DEFAULT        = 4;
46    const TYPE_WRITEABLE      = 8;
47    const TYPE_READONLY       = 16;
48
49    // public properties (mandatory)
50
51    /** @var string Name of the primary key field of this addressbook. Used to search for previously retrieved IDs. */
52    public $primary_key;
53
54    /** @var bool True if the addressbook supports contact groups. */
55    public $groups = false;
56
57    /**
58     * @var bool True if the addressbook supports exporting contact groups. Requires the implementation of
59     *              get_record_groups().
60     */
61    public $export_groups = true;
62
63    /** @var bool True if the addressbook is read-only. */
64    public $readonly = true;
65
66    /**
67     * @var bool True if the addressbook does not support listing all records but needs use of the search function.
68     */
69    public $searchonly = false;
70
71    /** @var bool True if the addressbook supports restoring deleted contacts. */
72    public $undelete = false;
73
74    /** @var bool True if the addressbook is ready to be used. See rcmail_action_contacts_index::$CONTACT_COLTYPES */
75    public $ready = false;
76
77    /**
78     * @var null|string|int If set, addressbook-specific identifier of the selected group. All contact listing and
79     *                      contact searches will be limited to contacts that belong to this group.
80     */
81    public $group_id = null;
82
83    /** @var int The current page of the listing. Numbering starts at 1. */
84    public $list_page = 1;
85
86    /** @var int The maximum number of records shown on a page. */
87    public $page_size = 10;
88
89    /** @var string Contact field by which to order listed records. */
90    public $sort_col = 'name';
91
92    /** @var string Whether sorting of records by $sort_col is done in ascending (ASC) or descending (DESC) order. */
93    public $sort_order = 'ASC';
94
95    /** @var string[] A list of record fields that contain dates. */
96    public $date_cols = [];
97
98    /** @var array Definition of the contact fields supported by the addressbook. */
99    public $coltypes = [
100        'name'      => ['limit' => 1],
101        'firstname' => ['limit' => 1],
102        'surname'   => ['limit' => 1],
103        'email'     => ['limit' => 1]
104    ];
105
106    /**
107     * @var string[] vCard additional fields mapping
108     */
109    public $vcard_map = [];
110
111    /** @var ?array Error state - hash array with the following fields: type, message */
112    protected $error;
113
114
115    /**
116     * Returns addressbook name (e.g. for addressbooks listing)
117     * @return string
118     */
119    abstract function get_name();
120
121    /**
122     * Sets a search filter.
123     *
124     * This affects the contact set considered when using the count() and list_records() operations to those
125     * contacts that match the filter conditions. If no search filter is set, all contacts in the addressbook are
126     * considered.
127     *
128     * This filter mechanism is applied in addition to other filter mechanisms, see the description of the count()
129     * operation.
130     *
131     * @param mixed $filter Search params to use in listing method, obtained by get_search_set()
132     * @return void
133     */
134    abstract function set_search_set($filter);
135
136    /**
137     * Getter for saved search properties.
138     *
139     * The filter representation is opaque to roundcube, but can be set again using set_search_set().
140     *
141     * @return mixed Search properties used by this class
142     */
143    abstract function get_search_set();
144
145    /**
146     * Reset saved results and search parameters
147     * @return void
148     */
149    abstract function reset();
150
151    /**
152     * Refresh saved search set after data has changed
153     *
154     * @return mixed New search set
155     */
156    function refresh_search()
157    {
158        return $this->get_search_set();
159    }
160
161    /**
162     * Lists the current set of contact records.
163     *
164     * See the description of count() for the criteria determining which contacts are considered for the listing.
165     *
166     * The actual records returned may be fewer, as only the records for the current page are returned. The returned
167     * records may be further limited by the $subset parameter, which means that only the first or last $subset records
168     * of the page are returned, depending on whether $subset is positive or negative. If $subset is 0, all records of
169     * the page are returned. The returned records are found in the $records property of the returned result set.
170     *
171     * Finally, the $first property of the returned result set contains the index into the total set of filtered records
172     * (i.e. not considering the segmentation into pages) of the first returned record before applying the $subset
173     * parameter (i.e., $first is always a multiple of the page size).
174     *
175     * The $nocount parameter is an optimization that allows to skip querying the total amount of records of the
176     * filtered set if the caller is only interested in the records. In this case, the $count property of the returned
177     * result set will simply contain the number of returned records, but the filtered set may contain more records than
178     * this.
179     *
180     * The result of the operation is internally cached for later retrieval using get_result().
181     *
182     * @param ?array $cols    List of columns to include in the returned records (null means all)
183     * @param int    $subset  Only return this number of records of the current page, use negative values for tail
184     * @param bool   $nocount True to skip the count query (select only)
185     *
186     * @return rcube_result_set Indexed list of contact records, each a hash array
187     */
188    abstract function list_records($cols = null, $subset = 0, $nocount = false);
189
190    /**
191     * Search records
192     *
193     * Depending on the given parameters the search() function operates in different ways (in the order listed):
194     *
195     * "Direct ID search" - when $fields is either 'ID' or $this->primary_key
196     *     - $values is either a string of contact IDs separated by self::SEPARATOR (,) or an array of contact IDs
197     *     - Any contact with one of the given IDs is returned
198     *
199     * "Advanced search" - when $value is an array
200     *     - Each value in $values is the search value for the field in $fields at the same index
201     *     - All fields must match their value to be included in the result ("AND" semantics)
202     *
203     * "Search all fields" - when $fields is '*' (note: $value is a single string)
204     *     - Any field must match the value to be included in the result ("OR" semantics)
205     *
206     * "Search given fields" - if none of the above matches
207     *     - Any of the given fields must match the value to be included in the result ("OR" semantics)
208     *
209     * All matching is done case insensitive.
210     *
211     * The search settings are remembered (see set_search_set()) until reset using the reset() function. They can be
212     * retrieved using get_search_set(). The remembered search settings must be considered by list_records() and
213     * count().
214     *
215     * The search mode can be set by the admin via the config.inc.php setting addressbook_search_mode.
216     * It is used as a bit mask, but the search modes are exclusive (SEARCH_GROUPS is combined with one of other modes):
217     *   SEARCH_ALL: substring search (*abc*)
218     *   SEARCH_STRICT: Exact match search (case insensitive =)
219     *   SEARCH_PREFIX: Prefix search (abc*)
220     *   SEARCH_GROUPS: include groups in search results (if supported)
221     *
222     * When records are requested in the returned rcube_result_set ($select is true), the results will only include the
223     * contacts of the current page (see list_page, page_size). The behavior is as described with the list_records
224     * function, and search() can be thought of as a sequence of set_search_set() and list_records() under that filter.
225     *
226     * If $nocount is true, the count property of the returned rcube_result_set will contain the amount of records
227     * contained within that set. Calling search() with $select=false and $nocount=true is not a meaningful use case and
228     * will result in an empty result set without records and a count property of 0, which gives no indication on the
229     * actual record set matching the given filter.
230     *
231     * The result of the operation is internally cached for later retrieval using get_result().
232     *
233     * @param string|string[] $fields   Field names to search in
234     * @param string|string[] $value    Search value, or array of values, one for each field in $fields
235     * @param int             $mode     Search mode. Sum of rcube_addressbook::SEARCH_*.
236     * @param bool            $select   True if records are requested in the result, false if count only
237     * @param bool            $nocount  True to skip the count query (select only)
238     * @param string|string[] $required Field or list of fields that cannot be empty
239     *
240     * @return rcube_result_set Contact records and 'count' value
241     */
242    abstract function search($fields, $value, $mode = 0, $select = true, $nocount = false, $required = []);
243
244    /**
245     * Count the number of contacts in the database matching the current filter criteria.
246     *
247     * The current filter criteria are defined by the search filter (see search()/set_search_set()) and the currently
248     * active group (see set_group()), if applicable.
249     *
250     * @return rcube_result_set Result set with values for 'count' and 'first'
251     */
252    abstract function count();
253
254    /**
255     * Return the last result set
256     *
257     * @return ?rcube_result_set Current result set or NULL if nothing selected yet
258     */
259    abstract function get_result();
260
261    /**
262     * Get a specific contact record
263     *
264     * @param mixed $id    Record identifier(s)
265     * @param bool  $assoc True to return record as associative array, otherwise a result set is returned
266     *
267     * @return rcube_result_set|array Result object with all record fields
268     */
269    abstract function get_record($id, $assoc = false);
270
271    /**
272     * Returns the last error occurred (e.g. when updating/inserting failed)
273     *
274     * @return ?array Hash array with the following fields: type, message. Null if no error set.
275     */
276    function get_error()
277    {
278        return $this->error;
279    }
280
281    /**
282     * Setter for errors for internal use
283     *
284     * @param int    $type    Error type (one of this class' error constants)
285     * @param string $message Error message (name of a text label)
286     */
287    protected function set_error($type, $message)
288    {
289        $this->error = ['type' => $type, 'message' => $message];
290    }
291
292    /**
293     * Close connection to source
294     * Called on script shutdown
295     */
296    function close() { }
297
298    /**
299     * Set internal list page
300     *
301     * @param int $page Page number to list
302     */
303    function set_page($page)
304    {
305        $this->list_page = (int) $page;
306    }
307
308    /**
309     * Set internal page size
310     *
311     * @param int $size Number of messages to display on one page
312     */
313    function set_pagesize($size)
314    {
315        $this->page_size = (int) $size;
316    }
317
318    /**
319     * Set internal sort settings
320     *
321     * @param ?string $sort_col   Sort column
322     * @param ?string $sort_order Sort order
323     */
324    function set_sort_order($sort_col, $sort_order = null)
325    {
326        if ($sort_col && (array_key_exists($sort_col, $this->coltypes) || in_array($sort_col, $this->coltypes))) {
327            $this->sort_col = $sort_col;
328        }
329
330        if ($sort_order) {
331            $this->sort_order = strtoupper($sort_order) == 'DESC' ? 'DESC' : 'ASC';
332        }
333    }
334
335    /**
336     * Check the given data before saving.
337     * If input isn't valid, the message to display can be fetched using get_error()
338     *
339     * @param array &$save_data Associative array with data to save
340     * @param bool  $autofix    Attempt to fix/complete record automatically
341     *
342     * @return bool True if input is valid, False if not.
343     */
344    public function validate(&$save_data, $autofix = false)
345    {
346        $rcube = rcube::get_instance();
347        $valid = true;
348
349        // check validity of email addresses
350        foreach ($this->get_col_values('email', $save_data, true) as $email) {
351            if (strlen($email)) {
352                if (!rcube_utils::check_email(rcube_utils::idn_to_ascii($email))) {
353                    $error = $rcube->gettext(['name' => 'emailformaterror', 'vars' => ['email' => $email]]);
354                    $this->set_error(self::ERROR_VALIDATE, $error);
355                    $valid = false;
356                    break;
357                }
358            }
359        }
360
361        // allow plugins to do contact validation and auto-fixing
362        $plugin = $rcube->plugins->exec_hook('contact_validate', [
363                'record'  => $save_data,
364                'autofix' => $autofix,
365                'valid'   => $valid,
366        ]);
367
368        if ($valid && !$plugin['valid']) {
369            $this->set_error(self::ERROR_VALIDATE, $plugin['error']);
370        }
371
372        if (is_array($plugin['record'])) {
373            $save_data = $plugin['record'];
374        }
375
376        return $plugin['valid'];
377    }
378
379    /**
380     * Create a new contact record
381     *
382     * @param array $save_data Associative array with save data
383     *                         Keys:   Field name with optional section in the form FIELD:SECTION
384     *                         Values: Field value. Can be either a string or an array of strings for multiple values
385     * @param bool  $check     True to check for duplicates first
386     *
387     * @return mixed The created record ID on success, False on error
388     */
389    function insert($save_data, $check = false)
390    {
391        // empty for read-only address books
392    }
393
394    /**
395     * Create new contact records for every item in the record set
396     *
397     * @param rcube_result_set $recset Recordset to insert
398     * @param bool             $check  True to check for duplicates first
399     *
400     * @return array List of created record IDs
401     */
402    function insertMultiple($recset, $check = false)
403    {
404        $ids = [];
405        if ($recset instanceof rcube_result_set) {
406            while ($row = $recset->next()) {
407                if ($insert = $this->insert($row, $check)) {
408                    $ids[] = $insert;
409                }
410            }
411        }
412
413        return $ids;
414    }
415
416    /**
417     * Update a specific contact record
418     *
419     * @param mixed $id        Record identifier
420     * @param array $save_cols Associative array with save data
421     *                         Keys:   Field name with optional section in the form FIELD:SECTION
422     *                         Values: Field value. Can be either a string or an array of strings for multiple values
423     *
424     * @return mixed On success if ID has been changed returns ID, otherwise True, False on error
425     */
426    function update($id, $save_cols)
427    {
428        // empty for read-only address books
429    }
430
431    /**
432     * Mark one or more contact records as deleted
433     *
434     * @param array $ids   Record identifiers
435     * @param bool  $force Remove records irreversible (see self::undelete)
436     *
437     * @return int|false Number of removed records, False on failure
438     */
439    function delete($ids, $force = true)
440    {
441        // empty for read-only address books
442    }
443
444    /**
445     * Unmark delete flag on contact record(s)
446     *
447     * @param array $ids Record identifiers
448     */
449    function undelete($ids)
450    {
451        // empty for read-only address books
452    }
453
454    /**
455     * Mark all records in database as deleted
456     *
457     * @param bool $with_groups Remove also groups
458     */
459    function delete_all($with_groups = false)
460    {
461        // empty for read-only address books
462    }
463
464    /**
465     * Sets/clears the current group.
466     *
467     * This affects the contact set considered when using the count(), list_records() and search() operations to those
468     * contacts that belong to the given group. If no current group is set, all contacts in the addressbook are
469     * considered.
470     *
471     * This filter mechanism is applied in addition to other filter mechanisms, see the description of the count()
472     * operation.
473     *
474     * @param null|0|string $gid Database identifier of the group. 0/"0"/null to reset the group filter.
475     */
476    function set_group($group_id)
477    {
478        // empty for address books don't supporting groups
479    }
480
481    /**
482     * List all active contact groups of this source
483     *
484     * @param ?string $search Optional search string to match group name
485     * @param int     $mode   Search mode. Sum of self::SEARCH_*
486     *
487     * @return array Indexed list of contact groups, each a hash array
488     */
489    function list_groups($search = null, $mode = 0)
490    {
491        // empty for address books don't supporting groups
492        return [];
493    }
494
495    /**
496     * Get group properties such as name and email address(es)
497     *
498     * @param string $group_id Group identifier
499     *
500     * @return ?array Group properties as hash array, null in case of error.
501     */
502    function get_group($group_id)
503    {
504        // empty for address books don't supporting groups
505        return null;
506    }
507
508    /**
509     * Create a contact group with the given name
510     *
511     * @param string $name The group name
512     *
513     * @return array|false False on error, array with record props in success
514     */
515    function create_group($name)
516    {
517        // empty for address books don't supporting groups
518        return false;
519    }
520
521    /**
522     * Delete the given group and all linked group members
523     *
524     * @param string $group_id Group identifier
525     *
526     * @return bool True on success, false if no data was changed
527     */
528    function delete_group($group_id)
529    {
530        // empty for address books don't supporting groups
531        return false;
532    }
533
534    /**
535     * Rename a specific contact group
536     *
537     * @param string $group_id Group identifier
538     * @param string $newname  New name to set for this group
539     * @param string &$newid   New group identifier (if changed, otherwise don't set)
540     *
541     * @return string|false New name on success, false if no data was changed
542     */
543    function rename_group($group_id, $newname, &$newid)
544    {
545        // empty for address books don't supporting groups
546        return false;
547    }
548
549    /**
550     * Add the given contact records the a certain group
551     *
552     * @param string       $group_id Group identifier
553     * @param array|string $ids      List of contact identifiers to be added
554     *
555     * @return int Number of contacts added
556     */
557    function add_to_group($group_id, $ids)
558    {
559        // empty for address books don't supporting groups
560        return 0;
561    }
562
563    /**
564     * Remove the given contact records from a certain group
565     *
566     * @param string       $group_id Group identifier
567     * @param array|string $ids      List of contact identifiers to be removed
568     *
569     * @return int Number of deleted group members
570     */
571    function remove_from_group($group_id, $ids)
572    {
573        // empty for address books don't supporting groups
574        return 0;
575    }
576
577    /**
578     * Get group assignments of a specific contact record
579     *
580     * @param mixed $id Record identifier
581     *
582     * @return array List of assigned groups indexed by a group ID.
583     *               Every array element can be just a group name (string), or an array
584     *               with 'ID' and 'name' elements.
585     * @since 0.5-beta
586     */
587    function get_record_groups($id)
588    {
589        // empty for address books don't supporting groups
590        return [];
591    }
592
593    /**
594     * Utility function to return all values of a certain data column
595     * either as flat list or grouped by subtype
596     *
597     * @param string $col  Col name
598     * @param array  $data Record data array as used for saving
599     * @param bool   $flat True to return one array with all values,
600     *                     False for hash array with values grouped by type
601     *
602     * @return array List of column values
603     */
604    public static function get_col_values($col, $data, $flat = false)
605    {
606        $out = [];
607        foreach ((array) $data as $c => $values) {
608            if ($c === $col || strpos($c, $col.':') === 0) {
609                if ($flat) {
610                    $out = array_merge($out, (array) $values);
611                }
612                else {
613                    list(, $type) = rcube_utils::explode(':', $c);
614                    if ($type !== null && isset($out[$type])) {
615                        $out[$type] = array_merge((array) $out[$type], (array) $values);
616                    }
617                    else {
618                        $out[$type] = (array) $values;
619                    }
620                }
621            }
622        }
623
624        // remove duplicates
625        if ($flat && !empty($out)) {
626            $out = array_unique($out);
627        }
628
629        return $out;
630    }
631
632    /**
633     * Compose a valid display name from the given structured contact data
634     *
635     * @param array $contact    Hash array with contact data as key-value pairs
636     * @param bool  $full_email Don't attempt to extract components from the email address
637     *
638     * @return string Display name
639     */
640    public static function compose_display_name($contact, $full_email = false)
641    {
642        $contact = rcube::get_instance()->plugins->exec_hook('contact_displayname', $contact);
643        $fn      = isset($contact['name']) ? $contact['name'] : '';
644
645        // default display name composition according to vcard standard
646        if (!$fn) {
647            $keys = ['prefix', 'firstname', 'middlename', 'surname', 'suffix'];
648            $fn   = implode(' ', array_filter(array_intersect_key($contact, array_flip($keys))));
649            $fn   = trim(preg_replace('/\s+/u', ' ', $fn));
650        }
651
652        // use email address part for name
653        $email = self::get_col_values('email', $contact, true);
654        $email = isset($email[0]) ? $email[0] : null;
655
656        if ($email && (empty($fn) || $fn == $email)) {
657            // return full email
658            if ($full_email) {
659                return $email;
660            }
661
662            list($emailname) = explode('@', $email);
663
664            if (preg_match('/(.*)[\.\-\_](.*)/', $emailname, $match)) {
665                $fn = trim(ucfirst($match[1]).' '.ucfirst($match[2]));
666            }
667            else {
668                $fn = ucfirst($emailname);
669            }
670        }
671
672        return $fn;
673    }
674
675    /**
676     * Compose the name to display in the contacts list for the given contact record.
677     * This respects the settings parameter how to list contacts.
678     *
679     * @param array $contact Hash array with contact data as key-value pairs
680     *
681     * @return string List name
682     */
683    public static function compose_list_name($contact)
684    {
685        static $compose_mode;
686
687        if (!isset($compose_mode)) {
688            $compose_mode = (int) rcube::get_instance()->config->get('addressbook_name_listing', 0);
689        }
690
691        $get_names = function ($contact, $fields) {
692            $result = [];
693            foreach ($fields as $field) {
694                if (!empty($contact[$field])) {
695                    $result[] = $contact[$field];
696                }
697            }
698            return $result;
699        };
700
701        switch ($compose_mode) {
702        case 3:
703            $names = $get_names($contact, ['firstname', 'middlename']);
704            if (!empty($contact['surname'])) {
705                array_unshift($names, $contact['surname'] . ',');
706            }
707            $fn = implode(' ', $names);
708            break;
709        case 2:
710            $keys = ['surname', 'firstname', 'middlename'];
711            $fn   = implode(' ', $get_names($contact, $keys));
712            break;
713        case 1:
714            $keys = ['firstname', 'middlename', 'surname'];
715            $fn   = implode(' ', $get_names($contact, $keys));
716            break;
717        case 0:
718            if (!empty($contact['name'])) {
719                $fn = $contact['name'];
720            }
721            else {
722                $keys = ['prefix', 'firstname', 'middlename', 'surname', 'suffix'];
723                $fn   = implode(' ', $get_names($contact, $keys));
724            }
725            break;
726        default:
727            $plugin = rcube::get_instance()->plugins->exec_hook('contact_listname', ['contact' => $contact]);
728            $fn     = $plugin['fn'];
729        }
730
731        $fn = trim($fn, ', ');
732        $fn = preg_replace('/\s+/u', ' ', $fn);
733
734        // fallbacks...
735        if ($fn === '') {
736            // ... display name
737            if (isset($contact['name']) && ($name = trim($contact['name']))) {
738                $fn = $name;
739            }
740            // ... organization
741            else if (isset($contact['organization']) && ($org = trim($contact['organization']))) {
742                $fn = $org;
743            }
744            // ... email address
745            else if (($email = self::get_col_values('email', $contact, true)) && !empty($email)) {
746                $fn = $email[0];
747            }
748        }
749
750        return $fn;
751    }
752
753    /**
754     * Build contact display name for autocomplete listing
755     *
756     * @param array  $contact Hash array with contact data as key-value pairs
757     * @param string $email   Optional email address
758     * @param string $name    Optional name (self::compose_list_name() result)
759     * @param string $templ   Optional template to use (defaults to the 'contact_search_name' config option)
760     *
761     * @return string Display name
762     */
763    public static function compose_search_name($contact, $email = null, $name = null, $templ = null)
764    {
765        static $template;
766
767        if (empty($templ) && !isset($template)) {  // cache this
768            $template = rcube::get_instance()->config->get('contact_search_name');
769            if (empty($template)) {
770                $template = '{name} <{email}>';
771            }
772        }
773
774        $result = $templ ?: $template;
775
776        if (preg_match_all('/\{[a-z]+\}/', $result, $matches)) {
777            foreach ($matches[0] as $key) {
778                $key   = trim($key, '{}');
779                $value = '';
780
781                switch ($key) {
782                case 'name':
783                    $value = $name ?: self::compose_list_name($contact);
784
785                    // If name(s) are undefined compose_list_name() may return an email address
786                    // here we prevent from returning the same name and email
787                    if ($name === $email && strpos($result, '{email}') !== false) {
788                        $value = '';
789                    }
790
791                    break;
792
793                case 'email':
794                    $value = $email;
795                    break;
796                }
797
798                if (empty($value)) {
799                    $value = strpos($key, ':') ? $contact[$key] : self::get_col_values($key, $contact, true);
800                    if (is_array($value) && isset($value[0])) {
801                        $value = $value[0];
802                    }
803                }
804
805                if (!is_string($value)) {
806                    $value = '';
807                }
808
809                $result = str_replace('{' . $key . '}', $value, $result);
810            }
811        }
812
813        $result = preg_replace('/\s+/u', ' ', $result);
814        $result = preg_replace('/\s*(<>|\(\)|\[\])/u', '', $result);
815        $result = trim($result, '/ ');
816
817        return $result;
818    }
819
820    /**
821     * Create a unique key for sorting contacts
822     *
823     * @param array  $contact  Contact record
824     * @param string $sort_col Sorting column name
825     *
826     * @return string Unique key
827     */
828    public static function compose_contact_key($contact, $sort_col)
829    {
830        $key = $contact[$sort_col];
831
832        // add email to a key to not skip contacts with the same name (#1488375)
833        if (($email = self::get_col_values('email', $contact, true)) && !empty($email)) {
834            $key .= ':' . implode(':', (array)$email);
835        }
836
837        // Make the key really unique (as we e.g. support contacts with no email)
838        $key .= ':' . $contact['sourceid'] . ':' . $contact['ID'];
839
840        return $key;
841    }
842
843    /**
844     * Compare search value with contact data
845     *
846     * @param string       $colname Data name
847     * @param string|array $value   Data value
848     * @param string       $search  Search value
849     * @param int          $mode    Search mode
850     *
851     * @return bool Comparison result
852     */
853    protected function compare_search_value($colname, $value, $search, $mode)
854    {
855        // The value is a date string, for date we'll
856        // use only strict comparison (mode = 1)
857        // @TODO: partial search, e.g. match only day and month
858        if (in_array($colname, $this->date_cols)) {
859            return (($value = rcube_utils::anytodatetime($value))
860                && ($search = rcube_utils::anytodatetime($search))
861                && $value->format('Ymd') == $search->format('Ymd'));
862        }
863
864        // Gender is a special value, must use strict comparison (#5757)
865        if ($colname == 'gender') {
866            $mode = self::SEARCH_STRICT;
867        }
868
869        // composite field, e.g. address
870        foreach ((array) $value as $val) {
871            $val = mb_strtolower($val);
872
873            if ($mode & self::SEARCH_STRICT) {
874                $got = ($val == $search);
875            }
876            else if ($mode & self::SEARCH_PREFIX) {
877                $got = ($search == substr($val, 0, strlen($search)));
878            }
879            else {
880                $got = (strpos($val, $search) !== false);
881            }
882
883            if ($got) {
884                return true;
885            }
886        }
887
888        return false;
889    }
890}
891