4 +-----------------------------------------------------------------------+
5 | program/include/rcube_vcard.php |
7 | This file is part of the RoundCube Webmail client |
8 | Copyright (C) 2008-2009, RoundCube Dev. - Switzerland |
9 | Licensed under the GNU GPL |
12 | Logical representation of a vcard address record |
13 +-----------------------------------------------------------------------+
14 | Author: Thomas Bruederli <roundcube@gmail.com> |
15 +-----------------------------------------------------------------------+
23 * Logical representation of a vcard-based address record
24 * Provides functions to parse and export vCard data format
26 * @package Addressbook
27 * @author Thomas Bruederli <roundcube@gmail.com>
33 'N' => array(array('','','','','')),
36 public $business = false;
44 public $email = array();
50 public function __construct($vcard = null)
58 * Load record from (internal, unfolded) vcard 3.0 format
60 * @param string vCard string to parse
62 public function load($vcard)
64 $this->raw = self::vcard_decode($vcard);
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));
75 foreach ((array)$this->raw['EMAIL'] as $i => $raw_email)
76 $this->email[$i] = is_array($raw_email) ? $raw_email[0] : $raw_email;
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;
89 * Convert the data structure into a vcard 3.0 string
91 public function export()
93 return self::rfc2425_fold(self::vcard_encode($this->raw));
98 * Setter for address record fields
100 * @param string Field name
101 * @param string Field value
102 * @param string Section name
104 public function set($field, $value, $section = 'HOME')
109 $this->raw['FN'][0] = $value;
113 $this->raw['N'][0][1] = $value;
117 $this->raw['N'][0][0] = $value;
121 $this->raw['NICKNAME'][0] = $value;
125 $this->raw['ORG'][0] = $value;
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'));
134 $this->raw['EMAIL'][$index][0] = $value;
142 * Find index with the '$type' attribute
144 * @param string Field name
145 * @return int Field index having $type set
147 private function get_type_index($field, $type = 'pref')
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']))
162 * Factory method to import a vcard file
164 * @param string vCard file content
165 * @return array List of rcube_vcard objects
167 public static function import($data)
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
179 $in_vcard_block = false;
181 foreach (preg_split("/[\r\n]+/", $data) as $i => $line) {
182 if ($in_vcard_block && !empty($line))
183 $vcard_block .= $line . "\n";
185 if (trim($line) == 'END:VCARD') {
187 $obj = new rcube_vcard(self::cleanup($vcard_block));
188 if (!empty($obj->displayname))
191 $in_vcard_block = false;
193 else if (trim($line) == 'BEGIN:VCARD') {
194 $vcard_block = $line . "\n";
195 $in_vcard_block = true;
204 * Normalize vcard data for better parsing
206 * @param string vCard block
207 * @return string Cleaned vcard block
209 private static function cleanup($vcard)
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',
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);
220 // remove vcard 2.1 charset definitions
221 $vcard = preg_replace('/;CHARSET=[^:;]+/', '', $vcard);
223 // if N doesn't have any semicolons, add some
224 $vcard = preg_replace('/^(N:[^;\R]*)$/m', '\1;;;;', $vcard);
229 private static function rfc2425_fold_callback($matches)
231 return ":\n ".rtrim(chunk_split($matches[1], 72, "\n "));
234 private static function rfc2425_fold($val)
236 return preg_replace_callback('/:([^\n]{72,})/', array('self', 'rfc2425_fold_callback'), $val) . "\n";
241 * Decodes a vcard block (vcard 3.0 format, unfolded)
242 * into an array structure
244 * @param string vCard block to parse
245 * @return array Raw data structure
247 private static function vcard_decode($vcard)
249 // Perform RFC2425 line unfolding
250 $vcard = preg_replace(array("/\r/", "/\n\s+/"), '', $vcard);
252 $lines = preg_split('/\r?\n/', $vcard);
255 for ($i=0; $i < count($lines); $i++) {
256 if (!preg_match('/^([^\\:]*):(.+)$/', $lines[$i], $line))
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);
266 if (!preg_match('/^(BEGIN|END)$/i', $line[1]) && preg_match_all('/([^\\;]+);?/', $line[1], $regs2)) {
268 $field = strtoupper($regs2[1][0]);
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];
277 $line[2] = self::decode_value($line[2], $value);
280 $entry[strtolower($key)] = array_merge((array)$entry[strtolower($key)], (array)self::vcard_unquote($value, ','));
282 else if ($attrid > 0) {
283 $entry[$key] = true; // true means attr without =value
287 $entry = array_merge($entry, (array)self::vcard_unquote($line[2]));
288 $data[$field][] = count($entry) > 1 ? $entry : $entry[0];
292 unset($data['VERSION']);
298 * Split quoted string
300 * @param string vCard string to split
301 * @param string Separator char/string
302 * @return array List with splitted values
304 private static function vcard_unquote($s, $sep = ';')
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);
314 return strtr($s, array("\r" => '', '\\\\' => '\\', '\n' => "\n", '\,' => ',', '\;' => ';', '\:' => ':'));
320 * Decode a given string with the encoding rule from ENCODING attributes
322 * @param string String to decode
323 * @param string Encoding type (quoted-printable and base64 supported)
324 * @return string Decoded 8bit value
326 private static function decode_value($value, $encoding)
328 switch (strtolower($encoding)) {
329 case 'quoted-printable':
330 return quoted_printable_decode($value);
333 return base64_decode($value);
342 * Encodes an entry for storage in our database (vcard 3.0 format, unfolded)
344 * @param array Raw data structure to encode
345 * @return string vCard encoded string
347 static function vcard_encode($data)
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)
354 foreach((array)$entries as $entry) {
356 if (is_array($entry)) {
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:...
364 foreach((array)$attrvalues as $attrvalue)
365 $attr .= ";$attrname=" . self::vcard_quote($attrvalue, ',');
373 $vcard .= self::vcard_quote($type) . $attr . ':' . self::vcard_quote($value) . "\n";
377 return "BEGIN:VCARD\nVERSION:3.0\n{$vcard}END:VCARD";
382 * Join indexed data array to a vcard quoted string
384 * @param array Field data
385 * @param string Separator
386 * @return string Joined and quoted string
388 private static function vcard_quote($s, $sep = ';')
391 foreach($s as $part) {
392 $r[] = self::vcard_quote($part, $sep);
394 return(implode($sep, (array)$r));
397 return strtr($s, array('\\' => '\\\\', "\r" => '', "\n" => '\n', ';' => '\;', ':' => '\:'));
403 * Returns UNICODE type based on BOM (Byte Order Mark)
405 * @param string Input string to test
406 * @return string Detected encoding
408 private static function detect_encoding($string)
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';
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');
422 if (function_exists('mb_detect_encoding') && ($enc = mb_detect_encoding($string, $encodings)))
425 // No match, check for UTF-8
426 // from http://w3.org/International/questions/qa-forms-utf-8.html
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)))
439 return rcmail::get_instance()->config->get('default_charset', 'ISO-8859-1'); # fallback to Latin-1