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