You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

rcube_contacts.php 34KB

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