alecpl
2010-10-01 928bcaedc0013a7b653647750108a8ab2c37d2a6
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                     |
A 8  | Copyright (C) 2008-2009, Roundcube Dev. - Switzerland                 |
ed132e 9  | Licensed under the GNU GPL                                            |
T 10  |                                                                       |
11  | PURPOSE:                                                              |
12  |   Logical representation of a vcard address record                    |
13  +-----------------------------------------------------------------------+
14  | Author: Thomas Bruederli <roundcube@gmail.com>                        |
15  +-----------------------------------------------------------------------+
16
1d786c 17  $Id$
ed132e 18
T 19 */
20
21
22 /**
23  * Logical representation of a vcard-based address record
24  * Provides functions to parse and export vCard data format
25  *
26  * @package    Addressbook
27  * @author     Thomas Bruederli <roundcube@gmail.com>
28  */
29 class rcube_vcard
30 {
0dbac3 31   private $raw = array(
T 32     'FN' => array(),
33     'N' => array(array('','','','','')),
34   );
ed132e 35
T 36   public $business = false;
37   public $displayname;
38   public $surname;
39   public $firstname;
40   public $middlename;
41   public $nickname;
42   public $organization;
43   public $notes;
44   public $email = array();
45
46
47   /**
48    * Constructor
49    */
5570ad 50   public function __construct($vcard = null, $charset = RCMAIL_CHARSET)
ed132e 51   {
T 52     if (!empty($vcard))
5570ad 53       $this->load($vcard, $charset);
ed132e 54   }
T 55
56
57   /**
58    * Load record from (internal, unfolded) vcard 3.0 format
59    *
60    * @param string vCard string to parse
61    */
5570ad 62   public function load($vcard, $charset = RCMAIL_CHARSET)
ed132e 63   {
T 64     $this->raw = self::vcard_decode($vcard);
5570ad 65     
T 66     // resolve charset parameters
67     if ($charset == null)
68       $this->raw = $this->charset_convert($this->raw);
ed132e 69
T 70     // find well-known address fields
5570ad 71     $this->displayname = $this->raw['FN'][0][0];
ed132e 72     $this->surname = $this->raw['N'][0][0];
T 73     $this->firstname = $this->raw['N'][0][1];
74     $this->middlename = $this->raw['N'][0][2];
5570ad 75     $this->nickname = $this->raw['NICKNAME'][0][0];
T 76     $this->organization = $this->raw['ORG'][0][0];
77     $this->business = ($this->raw['X-ABSHOWAS'][0][0] == 'COMPANY') || (join('', (array)$this->raw['N'][0]) == '' && !empty($this->organization));
ed132e 78     
T 79     foreach ((array)$this->raw['EMAIL'] as $i => $raw_email)
bb8781 80       $this->email[$i] = is_array($raw_email) ? $raw_email[0] : $raw_email;
ed132e 81     
T 82     // make the pref e-mail address the first entry in $this->email
83     $pref_index = $this->get_type_index('EMAIL', 'pref');
84     if ($pref_index > 0) {
85       $tmp = $this->email[0];
86       $this->email[0] = $this->email[$pref_index];
87       $this->email[$pref_index] = $tmp;
88     }
c56f1f 89
A 90     // make sure displayname is not empty (required by RFC2426)
91     if (!strlen($this->displayname)) {
92       // the same method is used in steps/mail/addcontact.inc
93       $this->displayname = ucfirst(preg_replace('/[\.\-]/', ' ',
94         substr($this->email[0], 0, strpos($this->email[0], '@'))));
95     }
ed132e 96   }
T 97
98
99   /**
100    * Convert the data structure into a vcard 3.0 string
101    */
102   public function export()
103   {
104     return self::rfc2425_fold(self::vcard_encode($this->raw));
105   }
106
107
108   /**
109    * Setter for address record fields
110    *
111    * @param string Field name
112    * @param string Field value
113    * @param string Section name
114    */
0dbac3 115   public function set($field, $value, $section = 'HOME')
ed132e 116   {
T 117     switch ($field) {
118       case 'name':
119       case 'displayname':
5570ad 120         $this->raw['FN'][0][0] = $value;
ed132e 121         break;
T 122         
123       case 'firstname':
124         $this->raw['N'][0][1] = $value;
125         break;
126         
127       case 'surname':
128         $this->raw['N'][0][0] = $value;
129         break;
130       
131       case 'nickname':
5570ad 132         $this->raw['NICKNAME'][0][0] = $value;
ed132e 133         break;
T 134         
135       case 'organization':
5570ad 136         $this->raw['ORG'][0][0] = $value;
ed132e 137         break;
T 138         
139       case 'email':
140         $index = $this->get_type_index('EMAIL', $section);
141         if (!is_array($this->raw['EMAIL'][$index])) {
142           $this->raw['EMAIL'][$index] = array(0 => $value, 'type' => array('INTERNET', $section, 'pref'));
143         }
144         else {
145           $this->raw['EMAIL'][$index][0] = $value;
146         }
147         break;
148     }
149   }
150
151
152   /**
153    * Find index with the '$type' attribute
154    *
155    * @param string Field name
156    * @return int Field index having $type set
157    */
158   private function get_type_index($field, $type = 'pref')
159   {
160     $result = 0;
161     if ($this->raw[$field]) {
162       foreach ($this->raw[$field] as $i => $data) {
163         if (is_array($data['type']) && in_array_nocase('pref', $data['type']))
164           $result = $i;
165       }
166     }
167     
168     return $result;
169   }
5570ad 170   
T 171   
172   /**
173    * Convert a whole vcard (array) to UTF-8.
174    * Each member value that has a charset parameter will be converted.
175    */
176   private function charset_convert($card)
177   {
178     foreach ($card as $key => $node) {
179       foreach ($node as $i => $subnode) {
180         if (is_array($subnode) && $subnode['charset'] && ($charset = $subnode['charset'][0])) {
181           foreach ($subnode as $j => $value) {
182             if (is_numeric($j) && is_string($value))
183               $card[$key][$i][$j] = rcube_charset_convert($value, $charset);
184           }
185           unset($card[$key][$i]['charset']);
186         }
187       }
188     }
189
190     return $card;
191   }
ed132e 192
T 193
194   /**
195    * Factory method to import a vcard file
196    *
197    * @param string vCard file content
198    * @return array List of rcube_vcard objects
199    */
200   public static function import($data)
201   {
202     $out = array();
203
5570ad 204     // check if charsets are specified (usually vcard version < 3.0 but this is not reliable)
T 205     if (preg_match('/charset=/i', substr($data, 0, 2048)))
206       $charset = null;
ed132e 207     // detect charset and convert to utf-8
5570ad 208     else if (($charset = self::detect_encoding($data)) && $charset != RCMAIL_CHARSET) {
T 209       $data = rcube_charset_convert($data, $charset);
6fa87f 210       $data = preg_replace(array('/^[\xFE\xFF]{2}/', '/^\xEF\xBB\xBF/', '/^\x00+/'), '', $data); // also remove BOM
5570ad 211       $charset = RCMAIL_CHARSET;
ed132e 212     }
T 213
214     $vcard_block = '';
215     $in_vcard_block = false;
216
217     foreach (preg_split("/[\r\n]+/", $data) as $i => $line) {
218       if ($in_vcard_block && !empty($line))
219         $vcard_block .= $line . "\n";
220
928bca 221       $line = trim($line);
A 222
223       if (preg_match('/^END:VCARD$/i', $line)) {
ed132e 224         // parse vcard
5570ad 225         $obj = new rcube_vcard(self::cleanup($vcard_block), $charset);
ed132e 226         if (!empty($obj->displayname))
T 227           $out[] = $obj;
228
229         $in_vcard_block = false;
230       }
928bca 231       else if (preg_match('/^BEGIN:VCARD$/i', $line)) {
ed132e 232         $vcard_block = $line . "\n";
T 233         $in_vcard_block = true;
234       }
235     }
236
237     return $out;
238   }
239
240
241   /**
242    * Normalize vcard data for better parsing
243    *
244    * @param string vCard block
245    * @return string Cleaned vcard block
246    */
247   private static function cleanup($vcard)
248   {
249     // Convert special types (like Skype) to normal type='skype' classes with this simple regex ;)
250     $vcard = preg_replace(
251       '/item(\d+)\.(TEL|URL)([^:]*?):(.*?)item\1.X-ABLabel:(?:_\$!<)?([\w-() ]*)(?:>!\$_)?./s',
252       '\2;type=\5\3:\4',
253       $vcard);
254
255     // Remove cruft like item1.X-AB*, item1.ADR instead of ADR, and empty lines
256     $vcard = preg_replace(array('/^item\d*\.X-AB.*$/m', '/^item\d*\./m', "/\n+/"), array('', '', "\n"), $vcard);
257
b58f11 258     // if N doesn't have any semicolons, add some 
T 259     $vcard = preg_replace('/^(N:[^;\R]*)$/m', '\1;;;;', $vcard);
ed132e 260
T 261     return $vcard;
262   }
263
478c7c 264   private static function rfc2425_fold_callback($matches)
A 265   {
266     return ":\n  ".rtrim(chunk_split($matches[1], 72, "\n  "));
267   }
ed132e 268
T 269   private static function rfc2425_fold($val)
270   {
5e6815 271     return preg_replace_callback('/:([^\n]{72,})/', array('self', 'rfc2425_fold_callback'), $val) . "\n";
ed132e 272   }
T 273
274
275   /**
276    * Decodes a vcard block (vcard 3.0 format, unfolded)
277    * into an array structure
278    *
279    * @param string vCard block to parse
280    * @return array Raw data structure
281    */
282   private static function vcard_decode($vcard)
283   {
284     // Perform RFC2425 line unfolding
285     $vcard = preg_replace(array("/\r/", "/\n\s+/"), '', $vcard);
286     
b58f11 287     $lines = preg_split('/\r?\n/', $vcard);
ed132e 288     $data = array();
b58f11 289     
T 290     for ($i=0; $i < count($lines); $i++) {
291       if (!preg_match('/^([^\\:]*):(.+)$/', $lines[$i], $line))
292           continue;
ed132e 293
b58f11 294       // convert 2.1-style "EMAIL;internet;home:" to 3.0-style "EMAIL;TYPE=internet;TYPE=home:"
T 295       if (($data['VERSION'][0] == "2.1") && preg_match('/^([^;]+);([^:]+)/', $line[1], $regs2) && !preg_match('/^TYPE=/i', $regs2[2])) {
296         $line[1] = $regs2[1];
297         foreach (explode(';', $regs2[2]) as $prop)
298           $line[1] .= ';' . (strpos($prop, '=') ? $prop : 'TYPE='.$prop);
ed132e 299       }
T 300
cc97ea 301       if (!preg_match('/^(BEGIN|END)$/i', $line[1]) && preg_match_all('/([^\\;]+);?/', $line[1], $regs2)) {
93af15 302         $entry = array();
cc97ea 303         $field = strtoupper($regs2[1][0]);
b58f11 304
T 305         foreach($regs2[1] as $attrid => $attr) {
306           if ((list($key, $value) = explode('=', $attr)) && $value) {
5570ad 307             $value = trim($value);
b58f11 308             if ($key == 'ENCODING') {
93af15 309               // add next line(s) to value string if QP line end detected
23a2ee 310               while ($value == 'QUOTED-PRINTABLE' && preg_match('/=$/', $lines[$i]))
b58f11 311                   $line[2] .= "\n" . $lines[++$i];
T 312               
313               $line[2] = self::decode_value($line[2], $value);
314             }
315             else
316               $entry[strtolower($key)] = array_merge((array)$entry[strtolower($key)], (array)self::vcard_unquote($value, ','));
317           }
318           else if ($attrid > 0) {
93af15 319             $entry[$key] = true;  // true means attr without =value
b58f11 320           }
T 321         }
322
93af15 323         $entry = array_merge($entry, (array)self::vcard_unquote($line[2]));
5570ad 324         $data[$field][] = $entry;
b58f11 325       }
ed132e 326     }
b58f11 327
T 328     unset($data['VERSION']);
ed132e 329     return $data;
T 330   }
331
332
333   /**
334    * Split quoted string
335    *
336    * @param string vCard string to split
337    * @param string Separator char/string
338    * @return array List with splitted values
339    */
340   private static function vcard_unquote($s, $sep = ';')
341   {
342     // break string into parts separated by $sep, but leave escaped $sep alone
343     if (count($parts = explode($sep, strtr($s, array("\\$sep" => "\007")))) > 1) {
344       foreach($parts as $s) {
345         $result[] = self::vcard_unquote(strtr($s, array("\007" => "\\$sep")), $sep);
346       }
347       return $result;
348     }
349     else {
350       return strtr($s, array("\r" => '', '\\\\' => '\\', '\n' => "\n", '\,' => ',', '\;' => ';', '\:' => ':'));
bb8781 351     }
T 352   }
353
354
355   /**
356    * Decode a given string with the encoding rule from ENCODING attributes
357    *
358    * @param string String to decode
359    * @param string Encoding type (quoted-printable and base64 supported)
360    * @return string Decoded 8bit value
361    */
362   private static function decode_value($value, $encoding)
363   {
364     switch (strtolower($encoding)) {
365       case 'quoted-printable':
366         return quoted_printable_decode($value);
367
368       case 'base64':
369         return base64_decode($value);
370
371       default:
372         return $value;
ed132e 373     }
T 374   }
375
376
377   /**
378    * Encodes an entry for storage in our database (vcard 3.0 format, unfolded)
379    *
380    * @param array Raw data structure to encode
381    * @return string vCard encoded string
382    */
383   static function vcard_encode($data)
384   {
385     foreach((array)$data as $type => $entries) {
386       /* valid N has 5 properties */
b58f11 387       while ($type == "N" && is_array($entries[0]) && count($entries[0]) < 5)
ed132e 388         $entries[0][] = "";
T 389
390       foreach((array)$entries as $entry) {
391         $attr = '';
392         if (is_array($entry)) {
393           $value = array();
394           foreach($entry as $attrname => $attrvalues) {
395             if (is_int($attrname))
396               $value[] = $attrvalues;
397             elseif ($attrvalues === true)
93af15 398               $attr .= ";$attrname";    // true means just tag, not tag=value, as in PHOTO;BASE64:...
ed132e 399             else {
T 400               foreach((array)$attrvalues as $attrvalue)
401                 $attr .= ";$attrname=" . self::vcard_quote($attrvalue, ',');
402             }
403           }
404         }
405         else {
406           $value = $entry;
407         }
408
409         $vcard .= self::vcard_quote($type) . $attr . ':' . self::vcard_quote($value) . "\n";
410       }
411     }
412
0dbac3 413     return "BEGIN:VCARD\nVERSION:3.0\n{$vcard}END:VCARD";
ed132e 414   }
T 415
416
417   /**
418    * Join indexed data array to a vcard quoted string
419    *
420    * @param array Field data
421    * @param string Separator
422    * @return string Joined and quoted string
423    */
424   private static function vcard_quote($s, $sep = ';')
425   {
426     if (is_array($s)) {
427       foreach($s as $part) {
428         $r[] = self::vcard_quote($part, $sep);
429       }
430       return(implode($sep, (array)$r));
431     }
432     else {
433       return strtr($s, array('\\' => '\\\\', "\r" => '', "\n" => '\n', ';' => '\;', ':' => '\:'));
434     }
435   }
436
437
438   /**
439    * Returns UNICODE type based on BOM (Byte Order Mark)
440    *
441    * @param string Input string to test
442    * @return string Detected encoding
443    */
444   private static function detect_encoding($string)
445   {
446     if (substr($string, 0, 4) == "\0\0\xFE\xFF") return 'UTF-32BE';  // Big Endian
447     if (substr($string, 0, 4) == "\xFF\xFE\0\0") return 'UTF-32LE';  // Little Endian
448     if (substr($string, 0, 2) == "\xFE\xFF")     return 'UTF-16BE';  // Big Endian
449     if (substr($string, 0, 2) == "\xFF\xFE")     return 'UTF-16LE';  // Little Endian
450     if (substr($string, 0, 3) == "\xEF\xBB\xBF") return 'UTF-8';
451
6fa87f 452     // use mb_detect_encoding()
T 453     $encodings = array('UTF-8', 'ISO-8859-1', 'ISO-8859-2', 'ISO-8859-3',
454       'ISO-8859-4', 'ISO-8859-5', 'ISO-8859-6', 'ISO-8859-7', 'ISO-8859-8', 'ISO-8859-9',
455       'ISO-8859-10', 'ISO-8859-13', 'ISO-8859-14', 'ISO-8859-15', 'ISO-8859-16',
456       'WINDOWS-1252', 'WINDOWS-1251', 'BIG5', 'GB2312');
457
ae9124 458     if (function_exists('mb_detect_encoding') && ($enc = mb_detect_encoding($string, $encodings)))
abdc58 459       return $enc;
A 460
ed132e 461     // No match, check for UTF-8
T 462     // from http://w3.org/International/questions/qa-forms-utf-8.html
463     if (preg_match('/\A(
464         [\x09\x0A\x0D\x20-\x7E]
465         | [\xC2-\xDF][\x80-\xBF]
466         | \xE0[\xA0-\xBF][\x80-\xBF]
467         | [\xE1-\xEC\xEE\xEF][\x80-\xBF]{2}
468         | \xED[\x80-\x9F][\x80-\xBF]
469         | \xF0[\x90-\xBF][\x80-\xBF]{2}
470         | [\xF1-\xF3][\x80-\xBF]{3}
471         | \xF4[\x80-\x8F][\x80-\xBF]{2}
472         )*\z/xs', substr($string, 0, 2048)))
473       return 'UTF-8';
474
ae9124 475     return rcmail::get_instance()->config->get('default_charset', 'ISO-8859-1'); # fallback to Latin-1
ed132e 476   }
T 477
478 }
479
480