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