rcube_addressbook.php 30.7 KB
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|0|string $gid Database identifier of the group. 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      = isset($contact['name']) ? $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 = isset($email[0]) ? $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;
    }
}