Welcome to mirror list, hosted at ThFree Co, Russian Federation.

rcube_addressbook.php « Roundcube « lib « program - github.com/roundcube/roundcubemail.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
blob: 5b0b903153b086fbb791232ec2d4bda3bf8c0724 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
<?php

/**
 +-----------------------------------------------------------------------+
 | This file is part of the Roundcube Webmail client                     |
 |                                                                       |
 | Copyright (C) The Roundcube Dev Team                                  |
 |                                                                       |
 | Licensed under the GNU General Public License version 3 or            |
 | any later version with exceptions for skins & plugins.                |
 | See the README file for a full license statement.                     |
 |                                                                       |
 | PURPOSE:                                                              |
 |   Interface to the local address book database                        |
 +-----------------------------------------------------------------------+
 | Author: Thomas Bruederli <roundcube@gmail.com>                        |
 +-----------------------------------------------------------------------+
*/

/**
 * Abstract skeleton of an address book/repository
 *
 * @package    Framework
 * @subpackage Addressbook
 */
abstract class rcube_addressbook
{
    // constants for error reporting
    const ERROR_READ_ONLY     = 1;
    const ERROR_NO_CONNECTION = 2;
    const ERROR_VALIDATE      = 3;
    const ERROR_SAVING        = 4;
    const ERROR_SEARCH        = 5;

    // search modes
    const SEARCH_ALL    = 0;
    const SEARCH_STRICT = 1;
    const SEARCH_PREFIX = 2;
    const SEARCH_GROUPS = 4;

    // contact types, note: some of these are used as addressbook source identifiers
    const TYPE_CONTACT        = 0;
    const TYPE_RECIPIENT      = 1;
    const TYPE_TRUSTED_SENDER = 2;
    const TYPE_DEFAULT        = 4;
    const TYPE_WRITEABLE      = 8;
    const TYPE_READONLY       = 16;

    // public properties (mandatory)

    /** @var string Name of the primary key field of this addressbook. Used to search for previously retrieved IDs. */
    public $primary_key;

    /** @var bool True if the addressbook supports contact groups. */
    public $groups = false;

    /**
     * @var bool True if the addressbook supports exporting contact groups. Requires the implementation of
     *              get_record_groups().
     */
    public $export_groups = true;

    /** @var bool True if the addressbook is read-only. */
    public $readonly = true;

    /**
     * @var bool True if the addressbook does not support listing all records but needs use of the search function.
     */
    public $searchonly = false;

    /** @var bool True if the addressbook supports restoring deleted contacts. */
    public $undelete = false;

    /** @var bool True if the addressbook is ready to be used. See rcmail_action_contacts_index::$CONTACT_COLTYPES */
    public $ready = false;

    /**
     * @var null|string|int If set, addressbook-specific identifier of the selected group. All contact listing and
     *                      contact searches will be limited to contacts that belong to this group.
     */
    public $group_id = null;

    /** @var int The current page of the listing. Numbering starts at 1. */
    public $list_page = 1;

    /** @var int The maximum number of records shown on a page. */
    public $page_size = 10;

    /** @var string Contact field by which to order listed records. */
    public $sort_col = 'name';

    /** @var string Whether sorting of records by $sort_col is done in ascending (ASC) or descending (DESC) order. */
    public $sort_order = 'ASC';

    /** @var string[] A list of record fields that contain dates. */
    public $date_cols = [];

    /** @var array Definition of the contact fields supported by the addressbook. */
    public $coltypes = [
        'name'      => ['limit' => 1],
        'firstname' => ['limit' => 1],
        'surname'   => ['limit' => 1],
        'email'     => ['limit' => 1]
    ];

    /**
     * @var string[] vCard additional fields mapping
     */
    public $vcard_map = [];

    /** @var ?array Error state - hash array with the following fields: type, message */
    protected $error;


    /**
     * Returns addressbook name (e.g. for addressbooks listing)
     * @return string
     */
    abstract function get_name();

    /**
     * Sets a search filter.
     *
     * This affects the contact set considered when using the count() and list_records() operations to those
     * contacts that match the filter conditions. If no search filter is set, all contacts in the addressbook are
     * considered.
     *
     * This filter mechanism is applied in addition to other filter mechanisms, see the description of the count()
     * operation.
     *
     * @param mixed $filter Search params to use in listing method, obtained by get_search_set()
     * @return void
     */
    abstract function set_search_set($filter);

    /**
     * Getter for saved search properties.
     *
     * The filter representation is opaque to roundcube, but can be set again using set_search_set().
     *
     * @return mixed Search properties used by this class
     */
    abstract function get_search_set();

    /**
     * Reset saved results and search parameters
     * @return void
     */
    abstract function reset();

    /**
     * Refresh saved search set after data has changed
     *
     * @return mixed New search set
     */
    function refresh_search()
    {
        return $this->get_search_set();
    }

    /**
     * Lists the current set of contact records.
     *
     * See the description of count() for the criteria determining which contacts are considered for the listing.
     *
     * The actual records returned may be fewer, as only the records for the current page are returned. The returned
     * records may be further limited by the $subset parameter, which means that only the first or last $subset records
     * of the page are returned, depending on whether $subset is positive or negative. If $subset is 0, all records of
     * the page are returned. The returned records are found in the $records property of the returned result set.
     *
     * Finally, the $first property of the returned result set contains the index into the total set of filtered records
     * (i.e. not considering the segmentation into pages) of the first returned record before applying the $subset
     * parameter (i.e., $first is always a multiple of the page size).
     *
     * The $nocount parameter is an optimization that allows to skip querying the total amount of records of the
     * filtered set if the caller is only interested in the records. In this case, the $count property of the returned
     * result set will simply contain the number of returned records, but the filtered set may contain more records than
     * this.
     *
     * The result of the operation is internally cached for later retrieval using get_result().
     *
     * @param ?array $cols    List of columns to include in the returned records (null means all)
     * @param int    $subset  Only return this number of records of the current page, use negative values for tail
     * @param bool   $nocount True to skip the count query (select only)
     *
     * @return rcube_result_set Indexed list of contact records, each a hash array
     */
    abstract function list_records($cols = null, $subset = 0, $nocount = false);

    /**
     * Search records
     *
     * Depending on the given parameters the search() function operates in different ways (in the order listed):
     *
     * "Direct ID search" - when $fields is either 'ID' or $this->primary_key
     *     - $values is either a string of contact IDs separated by self::SEPARATOR (,) or an array of contact IDs
     *     - Any contact with one of the given IDs is returned
     *
     * "Advanced search" - when $value is an array
     *     - Each value in $values is the search value for the field in $fields at the same index
     *     - All fields must match their value to be included in the result ("AND" semantics)
     *
     * "Search all fields" - when $fields is '*' (note: $value is a single string)
     *     - Any field must match the value to be included in the result ("OR" semantics)
     *
     * "Search given fields" - if none of the above matches
     *     - Any of the given fields must match the value to be included in the result ("OR" semantics)
     *
     * All matching is done case insensitive.
     *
     * The search settings are remembered (see set_search_set()) until reset using the reset() function. They can be
     * retrieved using get_search_set(). The remembered search settings must be considered by list_records() and
     * count().
     *
     * The search mode can be set by the admin via the config.inc.php setting addressbook_search_mode.
     * It is used as a bit mask, but the search modes are exclusive (SEARCH_GROUPS is combined with one of other modes):
     *   SEARCH_ALL: substring search (*abc*)
     *   SEARCH_STRICT: Exact match search (case insensitive =)
     *   SEARCH_PREFIX: Prefix search (abc*)
     *   SEARCH_GROUPS: include groups in search results (if supported)
     *
     * When records are requested in the returned rcube_result_set ($select is true), the results will only include the
     * contacts of the current page (see list_page, page_size). The behavior is as described with the list_records
     * function, and search() can be thought of as a sequence of set_search_set() and list_records() under that filter.
     *
     * If $nocount is true, the count property of the returned rcube_result_set will contain the amount of records
     * contained within that set. Calling search() with $select=false and $nocount=true is not a meaningful use case and
     * will result in an empty result set without records and a count property of 0, which gives no indication on the
     * actual record set matching the given filter.
     *
     * The result of the operation is internally cached for later retrieval using get_result().
     *
     * @param string|string[] $fields   Field names to search in
     * @param string|string[] $value    Search value, or array of values, one for each field in $fields
     * @param int             $mode     Search mode. Sum of rcube_addressbook::SEARCH_*.
     * @param bool            $select   True if records are requested in the result, false if count only
     * @param bool            $nocount  True to skip the count query (select only)
     * @param string|string[] $required Field or list of fields that cannot be empty
     *
     * @return rcube_result_set Contact records and 'count' value
     */
    abstract function search($fields, $value, $mode = 0, $select = true, $nocount = false, $required = []);

    /**
     * Count the number of contacts in the database matching the current filter criteria.
     *
     * The current filter criteria are defined by the search filter (see search()/set_search_set()) and the currently
     * active group (see set_group()), if applicable.
     *
     * @return rcube_result_set Result set with values for 'count' and 'first'
     */
    abstract function count();

    /**
     * Return the last result set
     *
     * @return ?rcube_result_set Current result set or NULL if nothing selected yet
     */
    abstract function get_result();

    /**
     * Get a specific contact record
     *
     * @param mixed $id    Record identifier(s)
     * @param bool  $assoc True to return record as associative array, otherwise a result set is returned
     *
     * @return rcube_result_set|array Result object with all record fields
     */
    abstract function get_record($id, $assoc = false);

    /**
     * Returns the last error occurred (e.g. when updating/inserting failed)
     *
     * @return ?array Hash array with the following fields: type, message. Null if no error set.
     */
    function get_error()
    {
        return $this->error;
    }

    /**
     * Setter for errors for internal use
     *
     * @param int    $type    Error type (one of this class' error constants)
     * @param string $message Error message (name of a text label)
     */
    protected function set_error($type, $message)
    {
        $this->error = ['type' => $type, 'message' => $message];
    }

    /**
     * Close connection to source
     * Called on script shutdown
     */
    function close() { }

    /**
     * Set internal list page
     *
     * @param int $page Page number to list
     */
    function set_page($page)
    {
        $this->list_page = (int) $page;
    }

    /**
     * Set internal page size
     *
     * @param int $size Number of messages to display on one page
     */
    function set_pagesize($size)
    {
        $this->page_size = (int) $size;
    }

    /**
     * Set internal sort settings
     *
     * @param ?string $sort_col   Sort column
     * @param ?string $sort_order Sort order
     */
    function set_sort_order($sort_col, $sort_order = null)
    {
        if ($sort_col && (array_key_exists($sort_col, $this->coltypes) || in_array($sort_col, $this->coltypes))) {
            $this->sort_col = $sort_col;
        }

        if ($sort_order) {
            $this->sort_order = strtoupper($sort_order) == 'DESC' ? 'DESC' : 'ASC';
        }
    }

    /**
     * Check the given data before saving.
     * If input isn't valid, the message to display can be fetched using get_error()
     *
     * @param array &$save_data Associative array with data to save
     * @param bool  $autofix    Attempt to fix/complete record automatically
     *
     * @return bool True if input is valid, False if not.
     */
    public function validate(&$save_data, $autofix = false)
    {
        $rcube = rcube::get_instance();
        $valid = true;

        // check validity of email addresses
        foreach ($this->get_col_values('email', $save_data, true) as $email) {
            if (strlen($email)) {
                if (!rcube_utils::check_email(rcube_utils::idn_to_ascii($email))) {
                    $error = $rcube->gettext(['name' => 'emailformaterror', 'vars' => ['email' => $email]]);
                    $this->set_error(self::ERROR_VALIDATE, $error);
                    $valid = false;
                    break;
                }
            }
        }

        // allow plugins to do contact validation and auto-fixing
        $plugin = $rcube->plugins->exec_hook('contact_validate', [
                'record'  => $save_data,
                'autofix' => $autofix,
                'valid'   => $valid,
        ]);

        if ($valid && !$plugin['valid']) {
            $this->set_error(self::ERROR_VALIDATE, $plugin['error']);
        }

        if (is_array($plugin['record'])) {
            $save_data = $plugin['record'];
        }

        return $plugin['valid'];
    }

    /**
     * Create a new contact record
     *
     * @param array $save_data 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  $check     True to check for duplicates first
     *
     * @return mixed The created record ID on success, False on error
     */
    function insert($save_data, $check = false)
    {
        // empty for read-only address books
    }

    /**
     * Create new contact records for every item in the record set
     *
     * @param rcube_result_set $recset Recordset to insert
     * @param bool             $check  True to check for duplicates first
     *
     * @return array List of created record IDs
     */
    function insertMultiple($recset, $check = false)
    {
        $ids = [];
        if ($recset instanceof rcube_result_set) {
            while ($row = $recset->next()) {
                if ($insert = $this->insert($row, $check)) {
                    $ids[] = $insert;
                }
            }
        }

        return $ids;
    }

    /**
     * Update a specific contact record
     *
     * @param mixed $id        Record identifier
     * @param array $save_cols 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 mixed On success if ID has been changed returns ID, otherwise True, False on error
     */
    function update($id, $save_cols)
    {
        // empty for read-only address books
    }

    /**
     * Mark one or more contact records as deleted
     *
     * @param array $ids   Record identifiers
     * @param bool  $force Remove records irreversible (see self::undelete)
     *
     * @return int|false Number of removed records, False on failure
     */
    function delete($ids, $force = true)
    {
        // empty for read-only address books
    }

    /**
     * Unmark delete flag on contact record(s)
     *
     * @param array $ids Record identifiers
     */
    function undelete($ids)
    {
        // empty for read-only address books
    }

    /**
     * Mark all records in database as deleted
     *
     * @param bool $with_groups Remove also groups
     */
    function delete_all($with_groups = false)
    {
        // empty for read-only address books
    }

    /**
     * Sets/clears the current group.
     *
     * This affects the contact set considered when using the count(), list_records() and search() operations to those
     * contacts that belong to the given group. If no current group is set, all contacts in the addressbook are
     * considered.
     *
     * This filter mechanism is applied in addition to other filter mechanisms, see the description of the count()
     * operation.
     *
     * @param null|int|string $gid Database identifier of the group. Use 0/"0"/null to reset the group filter.
     */
    function set_group($group_id)
    {
        // empty for address books don't supporting groups
    }

    /**
     * List all active contact groups of this source
     *
     * @param ?string $search Optional search string to match group name
     * @param int     $mode   Search mode. Sum of self::SEARCH_*
     *
     * @return array Indexed list of contact groups, each a hash array
     */
    function list_groups($search = null, $mode = 0)
    {
        // empty for address books don't supporting groups
        return [];
    }

    /**
     * Get group properties such as name and email address(es)
     *
     * @param string $group_id Group identifier
     *
     * @return ?array Group properties as hash array, null in case of error.
     */
    function get_group($group_id)
    {
        // empty for address books don't supporting groups
        return null;
    }

    /**
     * Create a contact group with the given name
     *
     * @param string $name The group name
     *
     * @return array|false False on error, array with record props in success
     */
    function create_group($name)
    {
        // empty for address books don't supporting groups
        return false;
    }

    /**
     * Delete the given group and all linked group members
     *
     * @param string $group_id Group identifier
     *
     * @return bool True on success, false if no data was changed
     */
    function delete_group($group_id)
    {
        // empty for address books don't supporting groups
        return false;
    }

    /**
     * Rename a specific contact group
     *
     * @param string $group_id Group identifier
     * @param string $newname  New name to set for this group
     * @param string &$newid   New group identifier (if changed, otherwise don't set)
     *
     * @return string|false New name on success, false if no data was changed
     */
    function rename_group($group_id, $newname, &$newid)
    {
        // empty for address books don't supporting groups
        return false;
    }

    /**
     * Add the given contact records the a certain group
     *
     * @param string       $group_id Group identifier
     * @param array|string $ids      List of contact identifiers to be added
     *
     * @return int Number of contacts added
     */
    function add_to_group($group_id, $ids)
    {
        // empty for address books don't supporting groups
        return 0;
    }

    /**
     * Remove the given contact records from a certain group
     *
     * @param string       $group_id Group identifier
     * @param array|string $ids      List of contact identifiers to be removed
     *
     * @return int Number of deleted group members
     */
    function remove_from_group($group_id, $ids)
    {
        // empty for address books don't supporting groups
        return 0;
    }

    /**
     * Get group assignments of a specific contact record
     *
     * @param mixed $id Record identifier
     *
     * @return array List of assigned groups indexed by a group ID.
     *               Every array element can be just a group name (string), or an array
     *               with 'ID' and 'name' elements.
     * @since 0.5-beta
     */
    function get_record_groups($id)
    {
        // empty for address books don't supporting groups
        return [];
    }

    /**
     * Utility function to return all values of a certain data column
     * either as flat list or grouped by subtype
     *
     * @param string $col  Col name
     * @param array  $data Record data array as used for saving
     * @param bool   $flat True to return one array with all values,
     *                     False for hash array with values grouped by type
     *
     * @return array List of column values
     */
    public static function get_col_values($col, $data, $flat = false)
    {
        $out = [];
        foreach ((array) $data as $c => $values) {
            if ($c === $col || strpos($c, $col.':') === 0) {
                if ($flat) {
                    $out = array_merge($out, (array) $values);
                }
                else {
                    list(, $type) = rcube_utils::explode(':', $c);
                    if ($type !== null && isset($out[$type])) {
                        $out[$type] = array_merge((array) $out[$type], (array) $values);
                    }
                    else {
                        $out[$type] = (array) $values;
                    }
                }
            }
        }

        // remove duplicates
        if ($flat && !empty($out)) {
            $out = array_unique($out);
        }

        return $out;
    }

    /**
     * Compose a valid display name from the given structured contact data
     *
     * @param array $contact    Hash array with contact data as key-value pairs
     * @param bool  $full_email Don't attempt to extract components from the email address
     *
     * @return string Display name
     */
    public static function compose_display_name($contact, $full_email = false)
    {
        $contact = rcube::get_instance()->plugins->exec_hook('contact_displayname', $contact);
        $fn      = $contact['name'] ?? '';

        // default display name composition according to vcard standard
        if (!$fn) {
            $keys = ['prefix', 'firstname', 'middlename', 'surname', 'suffix'];
            $fn   = implode(' ', array_filter(array_intersect_key($contact, array_flip($keys))));
            $fn   = trim(preg_replace('/\s+/u', ' ', $fn));
        }

        // use email address part for name
        $email = self::get_col_values('email', $contact, true);
        $email = $email[0] ?? null;

        if ($email && (empty($fn) || $fn == $email)) {
            // return full email
            if ($full_email) {
                return $email;
            }

            list($emailname) = explode('@', $email);

            if (preg_match('/(.*)[\.\-\_](.*)/', $emailname, $match)) {
                $fn = trim(ucfirst($match[1]).' '.ucfirst($match[2]));
            }
            else {
                $fn = ucfirst($emailname);
            }
        }

        return $fn;
    }

    /**
     * Compose the name to display in the contacts list for the given contact record.
     * This respects the settings parameter how to list contacts.
     *
     * @param array $contact Hash array with contact data as key-value pairs
     *
     * @return string List name
     */
    public static function compose_list_name($contact)
    {
        static $compose_mode;

        if (!isset($compose_mode)) {
            $compose_mode = (int) rcube::get_instance()->config->get('addressbook_name_listing', 0);
        }

        $get_names = function ($contact, $fields) {
            $result = [];
            foreach ($fields as $field) {
                if (!empty($contact[$field])) {
                    $result[] = $contact[$field];
                }
            }
            return $result;
        };

        switch ($compose_mode) {
        case 3:
            $names = $get_names($contact, ['firstname', 'middlename']);
            if (!empty($contact['surname'])) {
                array_unshift($names, $contact['surname'] . ',');
            }
            $fn = implode(' ', $names);
            break;
        case 2:
            $keys = ['surname', 'firstname', 'middlename'];
            $fn   = implode(' ', $get_names($contact, $keys));
            break;
        case 1:
            $keys = ['firstname', 'middlename', 'surname'];
            $fn   = implode(' ', $get_names($contact, $keys));
            break;
        case 0:
            if (!empty($contact['name'])) {
                $fn = $contact['name'];
            }
            else {
                $keys = ['prefix', 'firstname', 'middlename', 'surname', 'suffix'];
                $fn   = implode(' ', $get_names($contact, $keys));
            }
            break;
        default:
            $plugin = rcube::get_instance()->plugins->exec_hook('contact_listname', ['contact' => $contact]);
            $fn     = $plugin['fn'];
        }

        $fn = trim($fn, ', ');
        $fn = preg_replace('/\s+/u', ' ', $fn);

        // fallbacks...
        if ($fn === '') {
            // ... display name
            if (isset($contact['name']) && ($name = trim($contact['name']))) {
                $fn = $name;
            }
            // ... organization
            else if (isset($contact['organization']) && ($org = trim($contact['organization']))) {
                $fn = $org;
            }
            // ... email address
            else if (($email = self::get_col_values('email', $contact, true)) && !empty($email)) {
                $fn = $email[0];
            }
        }

        return $fn;
    }

    /**
     * Build contact display name for autocomplete listing
     *
     * @param array  $contact Hash array with contact data as key-value pairs
     * @param string $email   Optional email address
     * @param string $name    Optional name (self::compose_list_name() result)
     * @param string $templ   Optional template to use (defaults to the 'contact_search_name' config option)
     *
     * @return string Display name
     */
    public static function compose_search_name($contact, $email = null, $name = null, $templ = null)
    {
        static $template;

        if (empty($templ) && !isset($template)) {  // cache this
            $template = rcube::get_instance()->config->get('contact_search_name');
            if (empty($template)) {
                $template = '{name} <{email}>';
            }
        }

        $result = $templ ?: $template;

        if (preg_match_all('/\{[a-z]+\}/', $result, $matches)) {
            foreach ($matches[0] as $key) {
                $key   = trim($key, '{}');
                $value = '';

                switch ($key) {
                case 'name':
                    $value = $name ?: self::compose_list_name($contact);

                    // If name(s) are undefined compose_list_name() may return an email address
                    // here we prevent from returning the same name and email
                    if ($name === $email && strpos($result, '{email}') !== false) {
                        $value = '';
                    }

                    break;

                case 'email':
                    $value = $email;
                    break;
                }

                if (empty($value)) {
                    $value = strpos($key, ':') ? $contact[$key] : self::get_col_values($key, $contact, true);
                    if (is_array($value) && isset($value[0])) {
                        $value = $value[0];
                    }
                }

                if (!is_string($value)) {
                    $value = '';
                }

                $result = str_replace('{' . $key . '}', $value, $result);
            }
        }

        $result = preg_replace('/\s+/u', ' ', $result);
        $result = preg_replace('/\s*(<>|\(\)|\[\])/u', '', $result);
        $result = trim($result, '/ ');

        return $result;
    }

    /**
     * Create a unique key for sorting contacts
     *
     * @param array  $contact  Contact record
     * @param string $sort_col Sorting column name
     *
     * @return string Unique key
     */
    public static function compose_contact_key($contact, $sort_col)
    {
        $key = isset($contact[$sort_col]) ? $contact[$sort_col] : null;

        // add email to a key to not skip contacts with the same name (#1488375)
        if (($email = self::get_col_values('email', $contact, true)) && !empty($email)) {
            $key .= ':' . implode(':', (array)$email);
        }

        // Make the key really unique (as we e.g. support contacts with no email)
        $key .= ':' . $contact['sourceid'] . ':' . $contact['ID'];

        return $key;
    }

    /**
     * Compare search value with contact data
     *
     * @param string       $colname Data name
     * @param string|array $value   Data value
     * @param string       $search  Search value
     * @param int          $mode    Search mode
     *
     * @return bool Comparison result
     */
    protected function compare_search_value($colname, $value, $search, $mode)
    {
        // The value is a date string, for date we'll
        // use only strict comparison (mode = 1)
        // @TODO: partial search, e.g. match only day and month
        if (in_array($colname, $this->date_cols)) {
            return (($value = rcube_utils::anytodatetime($value))
                && ($search = rcube_utils::anytodatetime($search))
                && $value->format('Ymd') == $search->format('Ymd'));
        }

        // Gender is a special value, must use strict comparison (#5757)
        if ($colname == 'gender') {
            $mode = self::SEARCH_STRICT;
        }

        // composite field, e.g. address
        foreach ((array) $value as $val) {
            $val = mb_strtolower($val);

            if ($mode & self::SEARCH_STRICT) {
                $got = ($val == $search);
            }
            else if ($mode & self::SEARCH_PREFIX) {
                $got = ($search == substr($val, 0, strlen($search)));
            }
            else {
                $got = (strpos($val, $search) !== false);
            }

            if ($got) {
                return true;
            }
        }

        return false;
    }
}