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