alecpl
2012-04-14 9870dac30de1b8d1bdeab57af3eeb7c89b21ef76
commit | author | age
ed132e 1 <?php
T 2
3 /*
4  +-----------------------------------------------------------------------+
5  | program/include/rcube_vcard.php                                       |
6  |                                                                       |
e019f2 7  | This file is part of the Roundcube Webmail client                     |
0501b6 8  | Copyright (C) 2008-2011, The Roundcube Dev Team                       |
7fe381 9  |                                                                       |
T 10  | Licensed under the GNU General Public License version 3 or            |
11  | any later version with exceptions for skins & plugins.                |
12  | See the README file for a full license statement.                     |
ed132e 13  |                                                                       |
T 14  | PURPOSE:                                                              |
15  |   Logical representation of a vcard address record                    |
16  +-----------------------------------------------------------------------+
17  | Author: Thomas Bruederli <roundcube@gmail.com>                        |
18  +-----------------------------------------------------------------------+
19
1d786c 20  $Id$
ed132e 21
T 22 */
23
24
25 /**
26  * Logical representation of a vcard-based address record
27  * Provides functions to parse and export vCard data format
28  *
29  * @package    Addressbook
30  * @author     Thomas Bruederli <roundcube@gmail.com>
31  */
32 class rcube_vcard
33 {
6bdb61 34   private static $values_decoded = false;
0dbac3 35   private $raw = array(
T 36     'FN' => array(),
37     'N' => array(array('','','','','')),
38   );
359e19 39   private static $fieldmap = array(
0501b6 40     'phone'    => 'TEL',
T 41     'birthday' => 'BDAY',
42     'website'  => 'URL',
43     'notes'    => 'NOTE',
44     'email'    => 'EMAIL',
45     'address'  => 'ADR',
0fbade 46     'jobtitle' => 'TITLE',
46285d 47     'department'  => 'X-DEPARTMENT',
0501b6 48     'gender'      => 'X-GENDER',
T 49     'maidenname'  => 'X-MAIDENNAME',
50     'anniversary' => 'X-ANNIVERSARY',
51     'assistant'   => 'X-ASSISTANT',
52     'manager'     => 'X-MANAGER',
53     'spouse'      => 'X-SPOUSE',
e7b6e9 54     'edit'        => 'X-AB-EDIT',
0501b6 55   );
31e00c 56   private $typemap = array('iPhone' => 'mobile', 'CELL' => 'mobile', 'WORK,FAX' => 'workfax');
4c4fe6 57   private $phonetypemap = array('HOME1' => 'HOME', 'BUSINESS1' => 'WORK', 'BUSINESS2' => 'WORK2', 'BUSINESSFAX' => 'WORK,FAX');
0501b6 58   private $addresstypemap = array('BUSINESS' => 'WORK');
T 59   private $immap = array('X-JABBER' => 'jabber', 'X-ICQ' => 'icq', 'X-MSN' => 'msn', 'X-AIM' => 'aim', 'X-YAHOO' => 'yahoo', 'X-SKYPE' => 'skype', 'X-SKYPE-USERNAME' => 'skype');
ed132e 60
T 61   public $business = false;
62   public $displayname;
63   public $surname;
64   public $firstname;
65   public $middlename;
66   public $nickname;
67   public $organization;
68   public $notes;
69   public $email = array();
70
359e19 71   public static $eol = "\r\n";
ed132e 72
T 73   /**
74    * Constructor
75    */
4d4a2f 76   public function __construct($vcard = null, $charset = RCMAIL_CHARSET, $detect = false, $fieldmap = array())
ed132e 77   {
4d4a2f 78     if (!empty($fielmap))
A 79       $this->extend_fieldmap($fieldmap);
80
ed132e 81     if (!empty($vcard))
6bdb61 82       $this->load($vcard, $charset, $detect);
ed132e 83   }
T 84
85
86   /**
87    * Load record from (internal, unfolded) vcard 3.0 format
88    *
89    * @param string vCard string to parse
6bdb61 90    * @param string Charset of string values
T 91    * @param boolean True if loading a 'foreign' vcard and extra heuristics for charset detection is required
ed132e 92    */
6bdb61 93   public function load($vcard, $charset = RCMAIL_CHARSET, $detect = false)
ed132e 94   {
6bdb61 95     self::$values_decoded = false;
ed132e 96     $this->raw = self::vcard_decode($vcard);
6bdb61 97
5570ad 98     // resolve charset parameters
6bdb61 99     if ($charset == null) {
T 100       $this->raw = self::charset_convert($this->raw);
101     }
102     // vcard has encoded values and charset should be detected
103     else if ($detect && self::$values_decoded &&
104       ($detected_charset = self::detect_encoding(self::vcard_encode($this->raw))) && $detected_charset != RCMAIL_CHARSET) {
105         $this->raw = self::charset_convert($this->raw, $detected_charset);
106     }
6b1999 107     
T 108     // consider FN empty if the same as the primary e-mail address
109     if ($this->raw['FN'][0][0] == $this->raw['EMAIL'][0][0])
110       $this->raw['FN'][0][0] = '';
ed132e 111
T 112     // find well-known address fields
5570ad 113     $this->displayname = $this->raw['FN'][0][0];
ed132e 114     $this->surname = $this->raw['N'][0][0];
T 115     $this->firstname = $this->raw['N'][0][1];
116     $this->middlename = $this->raw['N'][0][2];
5570ad 117     $this->nickname = $this->raw['NICKNAME'][0][0];
T 118     $this->organization = $this->raw['ORG'][0][0];
119     $this->business = ($this->raw['X-ABSHOWAS'][0][0] == 'COMPANY') || (join('', (array)$this->raw['N'][0]) == '' && !empty($this->organization));
99fc46 120
ed132e 121     foreach ((array)$this->raw['EMAIL'] as $i => $raw_email)
bb8781 122       $this->email[$i] = is_array($raw_email) ? $raw_email[0] : $raw_email;
99fc46 123
ed132e 124     // make the pref e-mail address the first entry in $this->email
T 125     $pref_index = $this->get_type_index('EMAIL', 'pref');
126     if ($pref_index > 0) {
127       $tmp = $this->email[0];
128       $this->email[0] = $this->email[$pref_index];
129       $this->email[$pref_index] = $tmp;
130     }
131   }
132
133
134   /**
0501b6 135    * Return vCard data as associative array to be unsed in Roundcube address books
T 136    *
137    * @return array Hash array with key-value pairs
138    */
139   public function get_assoc()
140   {
141     $out = array('name' => $this->displayname);
142     $typemap = $this->typemap;
99fc46 143
0501b6 144     // copy name fields to output array
3e2637 145     foreach (array('firstname','surname','middlename','nickname','organization') as $col) {
T 146       if (strlen($this->$col))
147         $out[$col] = $this->$col;
148     }
99fc46 149
3e2637 150     if ($this->raw['N'][0][3])
T 151       $out['prefix'] = $this->raw['N'][0][3];
152     if ($this->raw['N'][0][4])
153       $out['suffix'] = $this->raw['N'][0][4];
99fc46 154
0501b6 155     // convert from raw vcard data into associative data for Roundcube
4d4a2f 156     foreach (array_flip(self::$fieldmap) as $tag => $col) {
0501b6 157       foreach ((array)$this->raw[$tag] as $i => $raw) {
T 158         if (is_array($raw)) {
159           $k = -1;
160           $key = $col;
4d4a2f 161           $subtype = '';
99fc46 162
4d4a2f 163           if (!empty($raw['type'])) {
31e00c 164             $combined = join(',', self::array_filter((array)$raw['type'], 'internet,pref', true));
T 165             $subtype = $typemap[$combined] ? $typemap[$combined] : ($typemap[$raw['type'][++$k]] ? $typemap[$raw['type'][$k]] : strtolower($raw['type'][$k]));
4d4a2f 166             while ($k < count($raw['type']) && ($subtype == 'internet' || $subtype == 'pref'))
A 167               $subtype = $typemap[$raw['type'][++$k]] ? $typemap[$raw['type'][$k]] : strtolower($raw['type'][$k]);
168           }
99fc46 169
e9aa8c 170           // read vcard 2.1 subtype
T 171           if (!$subtype) {
172             foreach ($raw as $k => $v) {
173               if (!is_numeric($k) && $v === true && !in_array(strtolower($k), array('pref','internet','voice','base64'))) {
174                 $subtype = $typemap[$k] ? $typemap[$k] : strtolower($k);
175                 break;
176               }
177             }
178           }
0fbade 179
T 180           // force subtype if none set
4d4a2f 181           if (!$subtype && preg_match('/^(email|phone|address|website)/', $key))
0fbade 182             $subtype = 'other';
99fc46 183
0501b6 184           if ($subtype)
T 185             $key .= ':' . $subtype;
186
187           // split ADR values into assoc array
188           if ($tag == 'ADR') {
189             list(,, $value['street'], $value['locality'], $value['region'], $value['zipcode'], $value['country']) = $raw;
190             $out[$key][] = $value;
191           }
192           else
193             $out[$key][] = $raw[0];
194         }
195         else {
196           $out[$col][] = $raw;
197         }
198       }
199     }
99fc46 200
0501b6 201     // handle special IM fields as used by Apple
T 202     foreach ($this->immap as $tag => $type) {
203       foreach ((array)$this->raw[$tag] as $i => $raw) {
204         $out['im:'.$type][] = $raw[0];
205       }
206     }
99fc46 207
0501b6 208     // copy photo data
T 209     if ($this->raw['PHOTO'])
210       $out['photo'] = $this->raw['PHOTO'][0][0];
99fc46 211
0501b6 212     return $out;
T 213   }
214
215
216   /**
ed132e 217    * Convert the data structure into a vcard 3.0 string
T 218    */
569f83 219   public function export($folded = true)
ed132e 220   {
569f83 221     $vcard = self::vcard_encode($this->raw);
T 222     return $folded ? self::rfc2425_fold($vcard) : $vcard;
0501b6 223   }
99fc46 224
A 225
0501b6 226   /**
T 227    * Clear the given fields in the loaded vcard data
228    *
229    * @param array List of field names to be reset
230    */
231   public function reset($fields = null)
232   {
233     if (!$fields)
4d4a2f 234       $fields = array_merge(array_values(self::$fieldmap), array_keys($this->immap), array('FN','N','ORG','NICKNAME','EMAIL','ADR','BDAY'));
99fc46 235
0501b6 236     foreach ($fields as $f)
T 237       unset($this->raw[$f]);
238
239     if (!$this->raw['N'])
240       $this->raw['N'] = array(array('','','','',''));
241     if (!$this->raw['FN'])
242       $this->raw['FN'] = array();
99fc46 243
0501b6 244     $this->email = array();
ed132e 245   }
T 246
247
248   /**
249    * Setter for address record fields
250    *
251    * @param string Field name
252    * @param string Field value
0501b6 253    * @param string Type/section name
ed132e 254    */
0501b6 255   public function set($field, $value, $type = 'HOME')
ed132e 256   {
0501b6 257     $field = strtolower($field);
4c4fe6 258     $type_uc = strtoupper($type);
0501b6 259     $typemap = array_flip($this->typemap);
99fc46 260
ed132e 261     switch ($field) {
T 262       case 'name':
263       case 'displayname':
5570ad 264         $this->raw['FN'][0][0] = $value;
ed132e 265         break;
99fc46 266
0501b6 267       case 'surname':
T 268         $this->raw['N'][0][0] = $value;
269         break;
99fc46 270
ed132e 271       case 'firstname':
T 272         $this->raw['N'][0][1] = $value;
273         break;
99fc46 274
0501b6 275       case 'middlename':
T 276         $this->raw['N'][0][2] = $value;
ed132e 277         break;
99fc46 278
0501b6 279       case 'prefix':
T 280         $this->raw['N'][0][3] = $value;
281         break;
99fc46 282
0501b6 283       case 'suffix':
T 284         $this->raw['N'][0][4] = $value;
285         break;
99fc46 286
ed132e 287       case 'nickname':
5570ad 288         $this->raw['NICKNAME'][0][0] = $value;
ed132e 289         break;
99fc46 290
ed132e 291       case 'organization':
5570ad 292         $this->raw['ORG'][0][0] = $value;
ed132e 293         break;
99fc46 294
0501b6 295       case 'photo':
0fbade 296         if (strpos($value, 'http:') === 0) {
T 297             // TODO: fetch file from URL and save it locally?
298             $this->raw['PHOTO'][0] = array(0 => $value, 'URL' => true);
299         }
300         else {
301             $encoded = !preg_match('![^a-z0-9/=+-]!i', $value);
302             $this->raw['PHOTO'][0] = array(0 => $encoded ? $value : base64_encode($value), 'BASE64' => true);
303         }
0501b6 304         break;
99fc46 305
ed132e 306       case 'email':
4c4fe6 307         $this->raw['EMAIL'][] = array(0 => $value, 'type' => array_filter(array('INTERNET', $type_uc)));
0501b6 308         $this->email[] = $value;
T 309         break;
99fc46 310
0501b6 311       case 'im':
T 312         // save IM subtypes into extension fields
313         $typemap = array_flip($this->immap);
314         if ($field = $typemap[strtolower($type)])
315           $this->raw[$field][] = array(0 => $value);
316         break;
317
318       case 'birthday':
e9aa8c 319         if ($val = rcube_strtotime($value))
0501b6 320           $this->raw['BDAY'][] = array(0 => date('Y-m-d', $val), 'value' => array('date'));
T 321         break;
322
323       case 'address':
4c4fe6 324         if ($this->addresstypemap[$type_uc])
T 325           $type = $this->addresstypemap[$type_uc];
0501b6 326
T 327         $value = $value[0] ? $value : array('', '', $value['street'], $value['locality'], $value['region'], $value['zipcode'], $value['country']);
328
329         // fall through if not empty
330         if (!strlen(join('', $value)))
331           break;
332
333       default:
4c4fe6 334         if ($field == 'phone' && $this->phonetypemap[$type_uc])
T 335           $type = $this->phonetypemap[$type_uc];
0501b6 336
4d4a2f 337         if (($tag = self::$fieldmap[$field]) && (is_array($value) || strlen($value))) {
0501b6 338           $index = count($this->raw[$tag]);
T 339           $this->raw[$tag][$index] = (array)$value;
340           if ($type)
31e00c 341             $this->raw[$tag][$index]['type'] = explode(',', ($typemap[$type] ? $typemap[$type] : $type));
ed132e 342         }
T 343         break;
344     }
345   }
346
4e3ec4 347   /**
T 348    * Setter for individual vcard properties
349    *
350    * @param string VCard tag name
351    * @param array Value-set of this vcard property
352    * @param boolean Set to true if the value-set should be appended instead of replacing any existing value-set
353    */
354   public function set_raw($tag, $value, $append = false)
355   {
356     $index = $append ? count($this->raw[$tag]) : 0;
357     $this->raw[$tag][$index] = (array)$value;
358   }
359
ed132e 360
T 361   /**
362    * Find index with the '$type' attribute
363    *
364    * @param string Field name
365    * @return int Field index having $type set
366    */
367   private function get_type_index($field, $type = 'pref')
368   {
369     $result = 0;
370     if ($this->raw[$field]) {
371       foreach ($this->raw[$field] as $i => $data) {
372         if (is_array($data['type']) && in_array_nocase('pref', $data['type']))
373           $result = $i;
374       }
375     }
99fc46 376
ed132e 377     return $result;
T 378   }
99fc46 379
A 380
5570ad 381   /**
T 382    * Convert a whole vcard (array) to UTF-8.
6bdb61 383    * If $force_charset is null, each member value that has a charset parameter will be converted
5570ad 384    */
6bdb61 385   private static function charset_convert($card, $force_charset = null)
5570ad 386   {
T 387     foreach ($card as $key => $node) {
388       foreach ($node as $i => $subnode) {
6bdb61 389         if (is_array($subnode) && (($charset = $force_charset) || ($subnode['charset'] && ($charset = $subnode['charset'][0])))) {
5570ad 390           foreach ($subnode as $j => $value) {
T 391             if (is_numeric($j) && is_string($value))
0c2596 392               $card[$key][$i][$j] = rcube_charset::convert($value, $charset);
5570ad 393           }
T 394           unset($card[$key][$i]['charset']);
395         }
396       }
397     }
398
399     return $card;
400   }
ed132e 401
T 402
403   /**
4d4a2f 404    * Extends fieldmap definition
A 405    */
406   public function extend_fieldmap($map)
407   {
408     if (is_array($map))
409       self::$fieldmap = array_merge($map, self::$fieldmap);
410   }
411
412
413   /**
ed132e 414    * Factory method to import a vcard file
T 415    *
416    * @param string vCard file content
417    * @return array List of rcube_vcard objects
418    */
419   public static function import($data)
420   {
421     $out = array();
422
5570ad 423     // check if charsets are specified (usually vcard version < 3.0 but this is not reliable)
T 424     if (preg_match('/charset=/i', substr($data, 0, 2048)))
425       $charset = null;
ed132e 426     // detect charset and convert to utf-8
5570ad 427     else if (($charset = self::detect_encoding($data)) && $charset != RCMAIL_CHARSET) {
0c2596 428       $data = rcube_charset::convert($data, $charset);
6fa87f 429       $data = preg_replace(array('/^[\xFE\xFF]{2}/', '/^\xEF\xBB\xBF/', '/^\x00+/'), '', $data); // also remove BOM
5570ad 430       $charset = RCMAIL_CHARSET;
ed132e 431     }
T 432
433     $vcard_block = '';
434     $in_vcard_block = false;
435
436     foreach (preg_split("/[\r\n]+/", $data) as $i => $line) {
437       if ($in_vcard_block && !empty($line))
438         $vcard_block .= $line . "\n";
439
928bca 440       $line = trim($line);
A 441
442       if (preg_match('/^END:VCARD$/i', $line)) {
ed132e 443         // parse vcard
4d4a2f 444         $obj = new rcube_vcard(self::cleanup($vcard_block), $charset, true, self::$fieldmap);
6b1999 445         if (!empty($obj->displayname) || !empty($obj->email))
ed132e 446           $out[] = $obj;
T 447
448         $in_vcard_block = false;
449       }
928bca 450       else if (preg_match('/^BEGIN:VCARD$/i', $line)) {
ed132e 451         $vcard_block = $line . "\n";
T 452         $in_vcard_block = true;
453       }
454     }
455
456     return $out;
457   }
458
459
460   /**
461    * Normalize vcard data for better parsing
462    *
463    * @param string vCard block
464    * @return string Cleaned vcard block
465    */
466   private static function cleanup($vcard)
467   {
468     // Convert special types (like Skype) to normal type='skype' classes with this simple regex ;)
469     $vcard = preg_replace(
0fbade 470       '/item(\d+)\.(TEL|EMAIL|URL)([^:]*?):(.*?)item\1.X-ABLabel:(?:_\$!<)?([\w-() ]*)(?:>!\$_)?./s',
ed132e 471       '\2;type=\5\3:\4',
0fbade 472       $vcard);
T 473
474     // convert Apple X-ABRELATEDNAMES into X-* fields for better compatibility
475     $vcard = preg_replace_callback(
476       '/item(\d+)\.(X-ABRELATEDNAMES)([^:]*?):(.*?)item\1.X-ABLabel:(?:_\$!<)?([\w-() ]*)(?:>!\$_)?./s',
477       array('self', 'x_abrelatednames_callback'),
ed132e 478       $vcard);
T 479
480     // Remove cruft like item1.X-AB*, item1.ADR instead of ADR, and empty lines
481     $vcard = preg_replace(array('/^item\d*\.X-AB.*$/m', '/^item\d*\./m', "/\n+/"), array('', '', "\n"), $vcard);
99fc46 482
e9aa8c 483     // convert X-WAB-GENDER to X-GENDER
T 484     if (preg_match('/X-WAB-GENDER:(\d)/', $vcard, $matches)) {
485       $value = $matches[1] == '2' ? 'male' : 'female';
486       $vcard = preg_replace('/X-WAB-GENDER:\d/', 'X-GENDER:' . $value, $vcard);
487     }
ed132e 488
b58f11 489     // if N doesn't have any semicolons, add some 
T 490     $vcard = preg_replace('/^(N:[^;\R]*)$/m', '\1;;;;', $vcard);
ed132e 491
T 492     return $vcard;
0fbade 493   }
99fc46 494
0fbade 495   private static function x_abrelatednames_callback($matches)
T 496   {
497     return 'X-' . strtoupper($matches[5]) . $matches[3] . ':'. $matches[4];
ed132e 498   }
T 499
478c7c 500   private static function rfc2425_fold_callback($matches)
A 501   {
bf80b5 502     // chunk_split string and avoid lines breaking multibyte characters
569f83 503     $c = 71;
T 504     $out .= substr($matches[1], 0, $c);
bf80b5 505     for ($n = $c; $c < strlen($matches[1]); $c++) {
569f83 506       // break if length > 75 or mutlibyte character starts after position 71
T 507       if ($n > 75 || ($n > 71 && ord($matches[1][$c]) >> 6 == 3)) {
58510f 508         $out .= "\r\n ";
bf80b5 509         $n = 0;
T 510       }
511       $out .= $matches[1][$c];
512       $n++;
513     }
514
515     return $out;
478c7c 516   }
ed132e 517
569f83 518   public static function rfc2425_fold($val)
ed132e 519   {
569f83 520     return preg_replace_callback('/([^\n]{72,})/', array('self', 'rfc2425_fold_callback'), $val);
ed132e 521   }
T 522
523
524   /**
525    * Decodes a vcard block (vcard 3.0 format, unfolded)
526    * into an array structure
527    *
528    * @param string vCard block to parse
529    * @return array Raw data structure
530    */
531   private static function vcard_decode($vcard)
532   {
99fc46 533     // Perform RFC2425 line unfolding and split lines
ed132e 534     $vcard = preg_replace(array("/\r/", "/\n\s+/"), '', $vcard);
99fc46 535     $lines = explode("\n", $vcard);
A 536     $data  = array();
537
b58f11 538     for ($i=0; $i < count($lines); $i++) {
99fc46 539       if (!preg_match('/^([^:]+):(.+)$/', $lines[$i], $line))
A 540         continue;
541
542       if (preg_match('/^(BEGIN|END)$/i', $line[1]))
543         continue;
ed132e 544
b58f11 545       // convert 2.1-style "EMAIL;internet;home:" to 3.0-style "EMAIL;TYPE=internet;TYPE=home:"
T 546       if (($data['VERSION'][0] == "2.1") && preg_match('/^([^;]+);([^:]+)/', $line[1], $regs2) && !preg_match('/^TYPE=/i', $regs2[2])) {
547         $line[1] = $regs2[1];
548         foreach (explode(';', $regs2[2]) as $prop)
549           $line[1] .= ';' . (strpos($prop, '=') ? $prop : 'TYPE='.$prop);
ed132e 550       }
T 551
99fc46 552       if (preg_match_all('/([^\\;]+);?/', $line[1], $regs2)) {
93af15 553         $entry = array();
cc97ea 554         $field = strtoupper($regs2[1][0]);
b58f11 555
T 556         foreach($regs2[1] as $attrid => $attr) {
557           if ((list($key, $value) = explode('=', $attr)) && $value) {
5570ad 558             $value = trim($value);
b58f11 559             if ($key == 'ENCODING') {
93af15 560               // add next line(s) to value string if QP line end detected
23a2ee 561               while ($value == 'QUOTED-PRINTABLE' && preg_match('/=$/', $lines[$i]))
b58f11 562                   $line[2] .= "\n" . $lines[++$i];
99fc46 563
b58f11 564               $line[2] = self::decode_value($line[2], $value);
T 565             }
566             else
567               $entry[strtolower($key)] = array_merge((array)$entry[strtolower($key)], (array)self::vcard_unquote($value, ','));
568           }
569           else if ($attrid > 0) {
93af15 570             $entry[$key] = true;  // true means attr without =value
b58f11 571           }
T 572         }
573
93af15 574         $entry = array_merge($entry, (array)self::vcard_unquote($line[2]));
5570ad 575         $data[$field][] = $entry;
b58f11 576       }
ed132e 577     }
b58f11 578
T 579     unset($data['VERSION']);
ed132e 580     return $data;
bb8781 581   }
T 582
583
584   /**
585    * Decode a given string with the encoding rule from ENCODING attributes
586    *
587    * @param string String to decode
588    * @param string Encoding type (quoted-printable and base64 supported)
589    * @return string Decoded 8bit value
590    */
591   private static function decode_value($value, $encoding)
592   {
593     switch (strtolower($encoding)) {
594       case 'quoted-printable':
6bdb61 595         self::$values_decoded = true;
bb8781 596         return quoted_printable_decode($value);
T 597
598       case 'base64':
6bdb61 599         self::$values_decoded = true;
bb8781 600         return base64_decode($value);
T 601
602       default:
603         return $value;
ed132e 604     }
T 605   }
606
607
608   /**
609    * Encodes an entry for storage in our database (vcard 3.0 format, unfolded)
610    *
611    * @param array Raw data structure to encode
612    * @return string vCard encoded string
613    */
614   static function vcard_encode($data)
615   {
616     foreach((array)$data as $type => $entries) {
617       /* valid N has 5 properties */
b58f11 618       while ($type == "N" && is_array($entries[0]) && count($entries[0]) < 5)
ed132e 619         $entries[0][] = "";
T 620
e84818 621       // make sure FN is not empty (required by RFC2426)
T 622       if ($type == "FN" && empty($entries))
623         $entries[0] = $data['EMAIL'][0][0];
624
ed132e 625       foreach((array)$entries as $entry) {
T 626         $attr = '';
627         if (is_array($entry)) {
628           $value = array();
629           foreach($entry as $attrname => $attrvalues) {
630             if (is_int($attrname))
631               $value[] = $attrvalues;
632             elseif ($attrvalues === true)
93af15 633               $attr .= ";$attrname";    // true means just tag, not tag=value, as in PHOTO;BASE64:...
ed132e 634             else {
T 635               foreach((array)$attrvalues as $attrvalue)
636                 $attr .= ";$attrname=" . self::vcard_quote($attrvalue, ',');
637             }
638           }
639         }
640         else {
641           $value = $entry;
642         }
643
4d4a2f 644         // skip empty entries
A 645         if (self::is_empty($value))
646           continue;
647
359e19 648         $vcard .= self::vcard_quote($type) . $attr . ':' . self::vcard_quote($value) . self::$eol;
ed132e 649       }
T 650     }
651
359e19 652     return 'BEGIN:VCARD' . self::$eol . 'VERSION:3.0' . self::$eol . $vcard . 'END:VCARD';
ed132e 653   }
T 654
655
656   /**
657    * Join indexed data array to a vcard quoted string
658    *
659    * @param array Field data
660    * @param string Separator
661    * @return string Joined and quoted string
662    */
663   private static function vcard_quote($s, $sep = ';')
664   {
665     if (is_array($s)) {
666       foreach($s as $part) {
667         $r[] = self::vcard_quote($part, $sep);
668       }
669       return(implode($sep, (array)$r));
670     }
671     else {
99fc46 672       return strtr($s, array('\\' => '\\\\', "\r" => '', "\n" => '\n', ',' => '\,', ';' => '\;'));
A 673     }
674   }
675
676
677   /**
678    * Split quoted string
679    *
680    * @param string vCard string to split
681    * @param string Separator char/string
682    * @return array List with splitted values
683    */
684   private static function vcard_unquote($s, $sep = ';')
685   {
686     // break string into parts separated by $sep, but leave escaped $sep alone
687     if (count($parts = explode($sep, strtr($s, array("\\$sep" => "\007")))) > 1) {
688       foreach($parts as $s) {
689         $result[] = self::vcard_unquote(strtr($s, array("\007" => "\\$sep")), $sep);
690       }
691       return $result;
692     }
693     else {
4e3ec4 694       return strtr($s, array("\r" => '', '\\\\' => '\\', '\n' => "\n", '\N' => "\n", '\,' => ',', '\;' => ';', '\:' => ':'));
ed132e 695     }
T 696   }
697
698
699   /**
4d4a2f 700    * Check if vCard entry is empty: empty string or an array with
A 701    * all entries empty.
702    *
703    * @param mixed $value Attribute value (string or array)
704    *
705    * @return bool True if the value is empty, False otherwise
706    */
707   private static function is_empty($value)
708   {
709     foreach ((array)$value as $v) {
710       if (((string)$v) !== '') {
711         return false;
712       }
713     }
714
715     return true;
716   }
717
31e00c 718   /**
T 719    * Extract array values by a filter
720    *
721    * @param array Array to filter
722    * @param keys Array or comma separated list of values to keep
723    * @param boolean Invert key selection: remove the listed values
724    * @return array The filtered array
725    */
726   private static function array_filter($arr, $values, $inverse = false)
727   {
728     if (!is_array($values))
729       $values = explode(',', $values);
730
731     $result = array();
732     $keep = array_flip((array)$values);
733     foreach ($arr as $key => $val)
734       if ($inverse != isset($keep[strtolower($val)]))
735         $result[$key] = $val;
736
737     return $result;
738   }
4d4a2f 739
A 740   /**
ed132e 741    * Returns UNICODE type based on BOM (Byte Order Mark)
T 742    *
743    * @param string Input string to test
744    * @return string Detected encoding
745    */
746   private static function detect_encoding($string)
747   {
748     if (substr($string, 0, 4) == "\0\0\xFE\xFF") return 'UTF-32BE';  // Big Endian
749     if (substr($string, 0, 4) == "\xFF\xFE\0\0") return 'UTF-32LE';  // Little Endian
750     if (substr($string, 0, 2) == "\xFE\xFF")     return 'UTF-16BE';  // Big Endian
751     if (substr($string, 0, 2) == "\xFF\xFE")     return 'UTF-16LE';  // Little Endian
752     if (substr($string, 0, 3) == "\xEF\xBB\xBF") return 'UTF-8';
753
0fbade 754     // heuristics
T 755     if ($string[0] == "\0" && $string[1] == "\0" && $string[2] == "\0" && $string[3] != "\0") return 'UTF-32BE';
756     if ($string[0] != "\0" && $string[1] == "\0" && $string[2] == "\0" && $string[3] == "\0") return 'UTF-32LE';
757     if ($string[0] == "\0" && $string[1] != "\0" && $string[2] == "\0" && $string[3] != "\0") return 'UTF-16BE';
758     if ($string[0] != "\0" && $string[1] == "\0" && $string[2] != "\0" && $string[3] == "\0") return 'UTF-16LE';
759
6fa87f 760     // use mb_detect_encoding()
T 761     $encodings = array('UTF-8', 'ISO-8859-1', 'ISO-8859-2', 'ISO-8859-3',
762       'ISO-8859-4', 'ISO-8859-5', 'ISO-8859-6', 'ISO-8859-7', 'ISO-8859-8', 'ISO-8859-9',
763       'ISO-8859-10', 'ISO-8859-13', 'ISO-8859-14', 'ISO-8859-15', 'ISO-8859-16',
764       'WINDOWS-1252', 'WINDOWS-1251', 'BIG5', 'GB2312');
765
ae9124 766     if (function_exists('mb_detect_encoding') && ($enc = mb_detect_encoding($string, $encodings)))
abdc58 767       return $enc;
A 768
ed132e 769     // No match, check for UTF-8
T 770     // from http://w3.org/International/questions/qa-forms-utf-8.html
771     if (preg_match('/\A(
772         [\x09\x0A\x0D\x20-\x7E]
773         | [\xC2-\xDF][\x80-\xBF]
774         | \xE0[\xA0-\xBF][\x80-\xBF]
775         | [\xE1-\xEC\xEE\xEF][\x80-\xBF]{2}
776         | \xED[\x80-\x9F][\x80-\xBF]
777         | \xF0[\x90-\xBF][\x80-\xBF]{2}
778         | [\xF1-\xF3][\x80-\xBF]{3}
779         | \xF4[\x80-\x8F][\x80-\xBF]{2}
780         )*\z/xs', substr($string, 0, 2048)))
781       return 'UTF-8';
782
0c2596 783     return rcube::get_instance()->config->get('default_charset', 'ISO-8859-1'); # fallback to Latin-1
ed132e 784   }
T 785
786 }