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