4 +-----------------------------------------------------------------------+
5 | program/include/rcube_vcard.php |
7 | This file is part of the RoundCube Webmail client |
8 | Copyright (C) 2008, 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);
230 private static function rfc2425_fold($val)
232 return preg_replace('/:([^\n]{72,})/e', '":\n ".rtrim(chunk_split("\\1", 72, "\n "))', $val) . "\n";
237 * Decodes a vcard block (vcard 3.0 format, unfolded)
238 * into an array structure
240 * @param string vCard block to parse
241 * @return array Raw data structure
243 private static function vcard_decode($vcard)
245 // Perform RFC2425 line unfolding
246 $vcard = preg_replace(array("/\r/", "/\n\s+/"), '', $vcard);
248 $lines = preg_split('/\r?\n/', $vcard);
251 for ($i=0; $i < count($lines); $i++) {
252 if (!preg_match('/^([^\\:]*):(.+)$/', $lines[$i], $line))
255 // convert 2.1-style "EMAIL;internet;home:" to 3.0-style "EMAIL;TYPE=internet;TYPE=home:"
256 if (($data['VERSION'][0] == "2.1") && preg_match('/^([^;]+);([^:]+)/', $line[1], $regs2) && !preg_match('/^TYPE=/i', $regs2[2])) {
257 $line[1] = $regs2[1];
258 foreach (explode(';', $regs2[2]) as $prop)
259 $line[1] .= ';' . (strpos($prop, '=') ? $prop : 'TYPE='.$prop);
262 if (!preg_match('/^(BEGIN|END)$/', $line[1]) && preg_match_all('/([^\\;]+);?/', $line[1], $regs2)) {
264 $field = $regs2[1][0];
266 foreach($regs2[1] as $attrid => $attr) {
267 if ((list($key, $value) = explode('=', $attr)) && $value) {
268 if ($key == 'ENCODING') {
269 # add next line(s) to value string if QP line end detected
270 while ($value == 'QUOTED-PRINTABLE' && ereg('=$', $lines[$i]))
271 $line[2] .= "\n" . $lines[++$i];
273 $line[2] = self::decode_value($line[2], $value);
276 $entry[strtolower($key)] = array_merge((array)$entry[strtolower($key)], (array)self::vcard_unquote($value, ','));
278 else if ($attrid > 0) {
279 $entry[$key] = true; # true means attr without =value
283 $entry[0] = self::vcard_unquote($line[2]);
284 $data[$field][] = count($entry) > 1 ? $entry : $entry[0];
288 unset($data['VERSION']);
295 * Split quoted string
297 * @param string vCard string to split
298 * @param string Separator char/string
299 * @return array List with splitted values
301 private static function vcard_unquote($s, $sep = ';')
303 // break string into parts separated by $sep, but leave escaped $sep alone
304 if (count($parts = explode($sep, strtr($s, array("\\$sep" => "\007")))) > 1) {
305 foreach($parts as $s) {
306 $result[] = self::vcard_unquote(strtr($s, array("\007" => "\\$sep")), $sep);
311 return strtr($s, array("\r" => '', '\\\\' => '\\', '\n' => "\n", '\,' => ',', '\;' => ';', '\:' => ':'));
317 * Decode a given string with the encoding rule from ENCODING attributes
319 * @param string String to decode
320 * @param string Encoding type (quoted-printable and base64 supported)
321 * @return string Decoded 8bit value
323 private static function decode_value($value, $encoding)
325 switch (strtolower($encoding)) {
326 case 'quoted-printable':
327 return quoted_printable_decode($value);
330 return base64_decode($value);
339 * Encodes an entry for storage in our database (vcard 3.0 format, unfolded)
341 * @param array Raw data structure to encode
342 * @return string vCard encoded string
344 static function vcard_encode($data)
346 foreach((array)$data as $type => $entries) {
347 /* valid N has 5 properties */
348 while ($type == "N" && is_array($entries[0]) && count($entries[0]) < 5)
351 foreach((array)$entries as $entry) {
353 if (is_array($entry)) {
355 foreach($entry as $attrname => $attrvalues) {
356 if (is_int($attrname))
357 $value[] = $attrvalues;
358 elseif ($attrvalues === true)
359 $attr .= ";$attrname"; # true means just tag, not tag=value, as in PHOTO;BASE64:...
361 foreach((array)$attrvalues as $attrvalue)
362 $attr .= ";$attrname=" . self::vcard_quote($attrvalue, ',');
370 $vcard .= self::vcard_quote($type) . $attr . ':' . self::vcard_quote($value) . "\n";
374 return "BEGIN:VCARD\nVERSION:3.0\n{$vcard}END:VCARD";
379 * Join indexed data array to a vcard quoted string
381 * @param array Field data
382 * @param string Separator
383 * @return string Joined and quoted string
385 private static function vcard_quote($s, $sep = ';')
388 foreach($s as $part) {
389 $r[] = self::vcard_quote($part, $sep);
391 return(implode($sep, (array)$r));
394 return strtr($s, array('\\' => '\\\\', "\r" => '', "\n" => '\n', ';' => '\;', ':' => '\:'));
400 * Returns UNICODE type based on BOM (Byte Order Mark)
402 * @param string Input string to test
403 * @return string Detected encoding
405 private static function detect_encoding($string)
407 if (substr($string, 0, 4) == "\0\0\xFE\xFF") return 'UTF-32BE'; // Big Endian
408 if (substr($string, 0, 4) == "\xFF\xFE\0\0") return 'UTF-32LE'; // Little Endian
409 if (substr($string, 0, 2) == "\xFE\xFF") return 'UTF-16BE'; // Big Endian
410 if (substr($string, 0, 2) == "\xFF\xFE") return 'UTF-16LE'; // Little Endian
411 if (substr($string, 0, 3) == "\xEF\xBB\xBF") return 'UTF-8';
413 // use mb_detect_encoding()
414 $encodings = array('UTF-8', 'ISO-8859-1', 'ISO-8859-2', 'ISO-8859-3',
415 'ISO-8859-4', 'ISO-8859-5', 'ISO-8859-6', 'ISO-8859-7', 'ISO-8859-8', 'ISO-8859-9',
416 'ISO-8859-10', 'ISO-8859-13', 'ISO-8859-14', 'ISO-8859-15', 'ISO-8859-16',
417 'WINDOWS-1252', 'WINDOWS-1251', 'BIG5', 'GB2312');
419 if (function_exists('mb_detect_encoding') && ($enc = mb_detect_encoding($string, $encodings)))
422 // No match, check for UTF-8
423 // from http://w3.org/International/questions/qa-forms-utf-8.html
425 [\x09\x0A\x0D\x20-\x7E]
426 | [\xC2-\xDF][\x80-\xBF]
427 | \xE0[\xA0-\xBF][\x80-\xBF]
428 | [\xE1-\xEC\xEE\xEF][\x80-\xBF]{2}
429 | \xED[\x80-\x9F][\x80-\xBF]
430 | \xF0[\x90-\xBF][\x80-\xBF]{2}
431 | [\xF1-\xF3][\x80-\xBF]{3}
432 | \xF4[\x80-\x8F][\x80-\xBF]{2}
433 )*\z/xs', substr($string, 0, 2048)))
436 return rcmail::get_instance()->config->get('default_charset', 'ISO-8859-1'); # fallback to Latin-1