Mercurial > hg > rc2
comparison program/lib/Roundcube/rcube_vcard.php @ 0:4681f974d28b
vanilla 1.3.3 distro, I hope
author | Charlie Root |
---|---|
date | Thu, 04 Jan 2018 15:52:31 -0500 |
parents | |
children |
comparison
equal
deleted
inserted
replaced
-1:000000000000 | 0:4681f974d28b |
---|---|
1 <?php | |
2 | |
3 /** | |
4 +-----------------------------------------------------------------------+ | |
5 | This file is part of the Roundcube Webmail client | | |
6 | Copyright (C) 2008-2012, The Roundcube Dev Team | | |
7 | | | |
8 | Licensed under the GNU General Public License version 3 or | | |
9 | any later version with exceptions for skins & plugins. | | |
10 | See the README file for a full license statement. | | |
11 | | | |
12 | PURPOSE: | | |
13 | Logical representation of a vcard address record | | |
14 +-----------------------------------------------------------------------+ | |
15 | Author: Thomas Bruederli <roundcube@gmail.com> | | |
16 | Author: Aleksander Machniak <alec@alec.pl> | | |
17 +-----------------------------------------------------------------------+ | |
18 */ | |
19 | |
20 /** | |
21 * Logical representation of a vcard-based address record | |
22 * Provides functions to parse and export vCard data format | |
23 * | |
24 * @package Framework | |
25 * @subpackage Addressbook | |
26 */ | |
27 class rcube_vcard | |
28 { | |
29 private static $values_decoded = false; | |
30 private $raw = array( | |
31 'FN' => array(), | |
32 'N' => array(array('','','','','')), | |
33 ); | |
34 private static $fieldmap = array( | |
35 'phone' => 'TEL', | |
36 'birthday' => 'BDAY', | |
37 'website' => 'URL', | |
38 'notes' => 'NOTE', | |
39 'email' => 'EMAIL', | |
40 'address' => 'ADR', | |
41 'jobtitle' => 'TITLE', | |
42 'department' => 'X-DEPARTMENT', | |
43 'gender' => 'X-GENDER', | |
44 'maidenname' => 'X-MAIDENNAME', | |
45 'anniversary' => 'X-ANNIVERSARY', | |
46 'assistant' => 'X-ASSISTANT', | |
47 'manager' => 'X-MANAGER', | |
48 'spouse' => 'X-SPOUSE', | |
49 'edit' => 'X-AB-EDIT', | |
50 'groups' => 'CATEGORIES', | |
51 ); | |
52 private $typemap = array( | |
53 'IPHONE' => 'mobile', | |
54 'CELL' => 'mobile', | |
55 'WORK,FAX' => 'workfax', | |
56 ); | |
57 private $phonetypemap = array( | |
58 'HOME1' => 'HOME', | |
59 'BUSINESS1' => 'WORK', | |
60 'BUSINESS2' => 'WORK2', | |
61 'BUSINESSFAX' => 'WORK,FAX', | |
62 'MOBILE' => 'CELL', | |
63 ); | |
64 private $addresstypemap = array( | |
65 'BUSINESS' => 'WORK', | |
66 ); | |
67 private $immap = array( | |
68 'X-JABBER' => 'jabber', | |
69 'X-ICQ' => 'icq', | |
70 'X-MSN' => 'msn', | |
71 'X-AIM' => 'aim', | |
72 'X-YAHOO' => 'yahoo', | |
73 'X-SKYPE' => 'skype', | |
74 'X-SKYPE-USERNAME' => 'skype', | |
75 ); | |
76 | |
77 public $business = false; | |
78 public $displayname; | |
79 public $surname; | |
80 public $firstname; | |
81 public $middlename; | |
82 public $nickname; | |
83 public $organization; | |
84 public $email = array(); | |
85 | |
86 public static $eol = "\r\n"; | |
87 | |
88 | |
89 /** | |
90 * Constructor | |
91 */ | |
92 public function __construct($vcard = null, $charset = RCUBE_CHARSET, $detect = false, $fieldmap = array()) | |
93 { | |
94 if (!empty($fieldmap)) { | |
95 $this->extend_fieldmap($fieldmap); | |
96 } | |
97 | |
98 if (!empty($vcard)) { | |
99 $this->load($vcard, $charset, $detect); | |
100 } | |
101 } | |
102 | |
103 /** | |
104 * Load record from (internal, unfolded) vcard 3.0 format | |
105 * | |
106 * @param string vCard string to parse | |
107 * @param string Charset of string values | |
108 * @param boolean True if loading a 'foreign' vcard and extra heuristics for charset detection is required | |
109 */ | |
110 public function load($vcard, $charset = RCUBE_CHARSET, $detect = false) | |
111 { | |
112 self::$values_decoded = false; | |
113 $this->raw = self::vcard_decode(self::cleanup($vcard)); | |
114 | |
115 // resolve charset parameters | |
116 if ($charset == null) { | |
117 $this->raw = self::charset_convert($this->raw); | |
118 } | |
119 // vcard has encoded values and charset should be detected | |
120 else if ($detect && self::$values_decoded | |
121 && ($detected_charset = self::detect_encoding(self::vcard_encode($this->raw))) | |
122 && $detected_charset != RCUBE_CHARSET | |
123 ) { | |
124 $this->raw = self::charset_convert($this->raw, $detected_charset); | |
125 } | |
126 | |
127 // find well-known address fields | |
128 $this->displayname = $this->raw['FN'][0][0]; | |
129 $this->surname = $this->raw['N'][0][0]; | |
130 $this->firstname = $this->raw['N'][0][1]; | |
131 $this->middlename = $this->raw['N'][0][2]; | |
132 $this->nickname = $this->raw['NICKNAME'][0][0]; | |
133 $this->organization = $this->raw['ORG'][0][0]; | |
134 $this->business = ($this->raw['X-ABSHOWAS'][0][0] == 'COMPANY') || (join('', (array)$this->raw['N'][0]) == '' && !empty($this->organization)); | |
135 | |
136 foreach ((array)$this->raw['EMAIL'] as $i => $raw_email) { | |
137 $this->email[$i] = is_array($raw_email) ? $raw_email[0] : $raw_email; | |
138 } | |
139 | |
140 // make the pref e-mail address the first entry in $this->email | |
141 $pref_index = $this->get_type_index('EMAIL', 'pref'); | |
142 if ($pref_index > 0) { | |
143 $tmp = $this->email[0]; | |
144 $this->email[0] = $this->email[$pref_index]; | |
145 $this->email[$pref_index] = $tmp; | |
146 } | |
147 | |
148 // fix broken vcards from Outlook that only supply ORG but not the required N or FN properties | |
149 if (!strlen(trim($this->displayname . $this->surname . $this->firstname)) && strlen($this->organization)) { | |
150 $this->displayname = $this->organization; | |
151 } | |
152 } | |
153 | |
154 /** | |
155 * Return vCard data as associative array to be unsed in Roundcube address books | |
156 * | |
157 * @return array Hash array with key-value pairs | |
158 */ | |
159 public function get_assoc() | |
160 { | |
161 $out = array('name' => $this->displayname); | |
162 $typemap = $this->typemap; | |
163 | |
164 // copy name fields to output array | |
165 foreach (array('firstname','surname','middlename','nickname','organization') as $col) { | |
166 if (strlen($this->$col)) { | |
167 $out[$col] = $this->$col; | |
168 } | |
169 } | |
170 | |
171 if ($this->raw['N'][0][3]) | |
172 $out['prefix'] = $this->raw['N'][0][3]; | |
173 if ($this->raw['N'][0][4]) | |
174 $out['suffix'] = $this->raw['N'][0][4]; | |
175 | |
176 // convert from raw vcard data into associative data for Roundcube | |
177 foreach (array_flip(self::$fieldmap) as $tag => $col) { | |
178 foreach ((array)$this->raw[$tag] as $i => $raw) { | |
179 if (is_array($raw)) { | |
180 $k = -1; | |
181 $key = $col; | |
182 $subtype = ''; | |
183 | |
184 if (!empty($raw['type'])) { | |
185 $combined = join(',', self::array_filter((array)$raw['type'], 'internet,pref', true)); | |
186 $combined = strtoupper($combined); | |
187 | |
188 if ($typemap[$combined]) { | |
189 $subtype = $typemap[$combined]; | |
190 } | |
191 else if ($typemap[$raw['type'][++$k]]) { | |
192 $subtype = $typemap[$raw['type'][$k]]; | |
193 } | |
194 else { | |
195 $subtype = strtolower($raw['type'][$k]); | |
196 } | |
197 | |
198 while ($k < count($raw['type']) && ($subtype == 'internet' || $subtype == 'pref')) { | |
199 $subtype = $typemap[$raw['type'][++$k]] ?: strtolower($raw['type'][$k]); | |
200 } | |
201 } | |
202 | |
203 // read vcard 2.1 subtype | |
204 if (!$subtype) { | |
205 foreach ($raw as $k => $v) { | |
206 if (!is_numeric($k) && $v === true && ($k = strtolower($k)) | |
207 && !in_array($k, array('pref','internet','voice','base64')) | |
208 ) { | |
209 $k_uc = strtoupper($k); | |
210 $subtype = $typemap[$k_uc] ?: $k; | |
211 break; | |
212 } | |
213 } | |
214 } | |
215 | |
216 // force subtype if none set | |
217 if (!$subtype && preg_match('/^(email|phone|address|website)/', $key)) { | |
218 $subtype = 'other'; | |
219 } | |
220 | |
221 if ($subtype) { | |
222 $key .= ':' . $subtype; | |
223 } | |
224 | |
225 // split ADR values into assoc array | |
226 if ($tag == 'ADR') { | |
227 list(,, $value['street'], $value['locality'], $value['region'], $value['zipcode'], $value['country']) = $raw; | |
228 $out[$key][] = $value; | |
229 } | |
230 else { | |
231 $out[$key][] = $raw[0]; | |
232 } | |
233 } | |
234 else { | |
235 $out[$col][] = $raw; | |
236 } | |
237 } | |
238 } | |
239 | |
240 // handle special IM fields as used by Apple | |
241 foreach ($this->immap as $tag => $type) { | |
242 foreach ((array)$this->raw[$tag] as $i => $raw) { | |
243 $out['im:'.$type][] = $raw[0]; | |
244 } | |
245 } | |
246 | |
247 // copy photo data | |
248 if ($this->raw['PHOTO']) { | |
249 $out['photo'] = $this->raw['PHOTO'][0][0]; | |
250 } | |
251 | |
252 return $out; | |
253 } | |
254 | |
255 /** | |
256 * Convert the data structure into a vcard 3.0 string | |
257 */ | |
258 public function export($folded = true) | |
259 { | |
260 $vcard = self::vcard_encode($this->raw); | |
261 return $folded ? self::rfc2425_fold($vcard) : $vcard; | |
262 } | |
263 | |
264 /** | |
265 * Clear the given fields in the loaded vcard data | |
266 * | |
267 * @param array List of field names to be reset | |
268 */ | |
269 public function reset($fields = null) | |
270 { | |
271 if (!$fields) { | |
272 $fields = array_merge(array_values(self::$fieldmap), array_keys($this->immap), | |
273 array('FN','N','ORG','NICKNAME','EMAIL','ADR','BDAY')); | |
274 } | |
275 | |
276 foreach ($fields as $f) { | |
277 unset($this->raw[$f]); | |
278 } | |
279 | |
280 if (!$this->raw['N']) { | |
281 $this->raw['N'] = array(array('','','','','')); | |
282 } | |
283 if (!$this->raw['FN']) { | |
284 $this->raw['FN'] = array(); | |
285 } | |
286 | |
287 $this->email = array(); | |
288 } | |
289 | |
290 /** | |
291 * Setter for address record fields | |
292 * | |
293 * @param string Field name | |
294 * @param string Field value | |
295 * @param string Type/section name | |
296 */ | |
297 public function set($field, $value, $type = 'HOME') | |
298 { | |
299 $field = strtolower($field); | |
300 $type_uc = strtoupper($type); | |
301 | |
302 switch ($field) { | |
303 case 'name': | |
304 case 'displayname': | |
305 $this->raw['FN'][0][0] = $this->displayname = $value; | |
306 break; | |
307 | |
308 case 'surname': | |
309 $this->raw['N'][0][0] = $this->surname = $value; | |
310 break; | |
311 | |
312 case 'firstname': | |
313 $this->raw['N'][0][1] = $this->firstname = $value; | |
314 break; | |
315 | |
316 case 'middlename': | |
317 $this->raw['N'][0][2] = $this->middlename = $value; | |
318 break; | |
319 | |
320 case 'prefix': | |
321 $this->raw['N'][0][3] = $value; | |
322 break; | |
323 | |
324 case 'suffix': | |
325 $this->raw['N'][0][4] = $value; | |
326 break; | |
327 | |
328 case 'nickname': | |
329 $this->raw['NICKNAME'][0][0] = $this->nickname = $value; | |
330 break; | |
331 | |
332 case 'organization': | |
333 $this->raw['ORG'][0][0] = $this->organization = $value; | |
334 break; | |
335 | |
336 case 'photo': | |
337 if (strpos($value, 'http:') === 0) { | |
338 // TODO: fetch file from URL and save it locally? | |
339 $this->raw['PHOTO'][0] = array(0 => $value, 'url' => true); | |
340 } | |
341 else { | |
342 $this->raw['PHOTO'][0] = array(0 => $value, 'base64' => (bool) preg_match('![^a-z0-9/=+-]!i', $value)); | |
343 } | |
344 break; | |
345 | |
346 case 'email': | |
347 $this->raw['EMAIL'][] = array(0 => $value, 'type' => array_filter(array('INTERNET', $type_uc))); | |
348 $this->email[] = $value; | |
349 break; | |
350 | |
351 case 'im': | |
352 // save IM subtypes into extension fields | |
353 $typemap = array_flip($this->immap); | |
354 if ($field = $typemap[strtolower($type)]) { | |
355 $this->raw[$field][] = array(0 => $value); | |
356 } | |
357 break; | |
358 | |
359 case 'birthday': | |
360 case 'anniversary': | |
361 if (($val = rcube_utils::anytodatetime($value)) && ($fn = self::$fieldmap[$field])) { | |
362 $this->raw[$fn][] = array(0 => $val->format('Y-m-d'), 'value' => array('date')); | |
363 } | |
364 break; | |
365 | |
366 case 'address': | |
367 if ($this->addresstypemap[$type_uc]) { | |
368 $type = $this->addresstypemap[$type_uc]; | |
369 } | |
370 | |
371 $value = $value[0] ? $value : array('', '', $value['street'], $value['locality'], $value['region'], $value['zipcode'], $value['country']); | |
372 | |
373 // fall through if not empty | |
374 if (!strlen(join('', $value))) { | |
375 break; | |
376 } | |
377 | |
378 default: | |
379 if ($field == 'phone' && $this->phonetypemap[$type_uc]) { | |
380 $type = $this->phonetypemap[$type_uc]; | |
381 } | |
382 | |
383 if (($tag = self::$fieldmap[$field]) && (is_array($value) || strlen($value))) { | |
384 $this->raw[$tag][] = (array) $value; | |
385 if ($type) { | |
386 $index = count($this->raw[$tag]) - 1; | |
387 $typemap = array_flip($this->typemap); | |
388 $this->raw[$tag][$index]['type'] = explode(',', $typemap[$type_uc] ?: $type); | |
389 } | |
390 } | |
391 else { | |
392 unset($this->raw[$tag]); | |
393 } | |
394 | |
395 break; | |
396 } | |
397 } | |
398 | |
399 /** | |
400 * Setter for individual vcard properties | |
401 * | |
402 * @param string VCard tag name | |
403 * @param array Value-set of this vcard property | |
404 * @param boolean Set to true if the value-set should be appended instead of replacing any existing value-set | |
405 */ | |
406 public function set_raw($tag, $value, $append = false) | |
407 { | |
408 $index = $append && isset($this->raw[$tag]) ? count($this->raw[$tag]) : 0; | |
409 $this->raw[$tag][$index] = (array)$value; | |
410 } | |
411 | |
412 /** | |
413 * Find index with the '$type' attribute | |
414 * | |
415 * @param string Field name | |
416 * | |
417 * @return int Field index having $type set | |
418 */ | |
419 private function get_type_index($field) | |
420 { | |
421 $result = 0; | |
422 if ($this->raw[$field]) { | |
423 foreach ($this->raw[$field] as $i => $data) { | |
424 if (is_array($data['type']) && in_array_nocase('pref', $data['type'])) { | |
425 $result = $i; | |
426 } | |
427 } | |
428 } | |
429 | |
430 return $result; | |
431 } | |
432 | |
433 /** | |
434 * Convert a whole vcard (array) to UTF-8. | |
435 * If $force_charset is null, each member value that has a charset parameter will be converted | |
436 */ | |
437 private static function charset_convert($card, $force_charset = null) | |
438 { | |
439 foreach ($card as $key => $node) { | |
440 foreach ($node as $i => $subnode) { | |
441 if (is_array($subnode) && (($charset = $force_charset) || ($subnode['charset'] && ($charset = $subnode['charset'][0])))) { | |
442 foreach ($subnode as $j => $value) { | |
443 if (is_numeric($j) && is_string($value)) { | |
444 $card[$key][$i][$j] = rcube_charset::convert($value, $charset); | |
445 } | |
446 } | |
447 unset($card[$key][$i]['charset']); | |
448 } | |
449 } | |
450 } | |
451 | |
452 return $card; | |
453 } | |
454 | |
455 /** | |
456 * Extends fieldmap definition | |
457 */ | |
458 public function extend_fieldmap($map) | |
459 { | |
460 if (is_array($map)) { | |
461 self::$fieldmap = array_merge($map, self::$fieldmap); | |
462 } | |
463 } | |
464 | |
465 /** | |
466 * Factory method to import a vcard file | |
467 * | |
468 * @param string vCard file content | |
469 * | |
470 * @return array List of rcube_vcard objects | |
471 */ | |
472 public static function import($data) | |
473 { | |
474 $out = array(); | |
475 | |
476 // check if charsets are specified (usually vcard version < 3.0 but this is not reliable) | |
477 if (preg_match('/charset=/i', substr($data, 0, 2048))) { | |
478 $charset = null; | |
479 } | |
480 // detect charset and convert to utf-8 | |
481 else if (($charset = self::detect_encoding($data)) && $charset != RCUBE_CHARSET) { | |
482 $data = rcube_charset::convert($data, $charset); | |
483 $data = preg_replace(array('/^[\xFE\xFF]{2}/', '/^\xEF\xBB\xBF/', '/^\x00+/'), '', $data); // also remove BOM | |
484 $charset = RCUBE_CHARSET; | |
485 } | |
486 | |
487 $vcard_block = ''; | |
488 $in_vcard_block = false; | |
489 | |
490 foreach (preg_split("/[\r\n]+/", $data) as $line) { | |
491 if ($in_vcard_block && !empty($line)) { | |
492 $vcard_block .= $line . "\n"; | |
493 } | |
494 | |
495 $line = trim($line); | |
496 | |
497 if (preg_match('/^END:VCARD$/i', $line)) { | |
498 // parse vcard | |
499 $obj = new rcube_vcard($vcard_block, $charset, true, self::$fieldmap); | |
500 // FN and N is required by vCard format (RFC 2426) | |
501 // on import we can be less restrictive, let's addressbook decide | |
502 if (!empty($obj->displayname) || !empty($obj->surname) || !empty($obj->firstname) || !empty($obj->email)) { | |
503 $out[] = $obj; | |
504 } | |
505 | |
506 $in_vcard_block = false; | |
507 } | |
508 else if (preg_match('/^BEGIN:VCARD$/i', $line)) { | |
509 $vcard_block = $line . "\n"; | |
510 $in_vcard_block = true; | |
511 } | |
512 } | |
513 | |
514 return $out; | |
515 } | |
516 | |
517 /** | |
518 * Normalize vcard data for better parsing | |
519 * | |
520 * @param string vCard block | |
521 * | |
522 * @return string Cleaned vcard block | |
523 */ | |
524 public static function cleanup($vcard) | |
525 { | |
526 // convert Apple X-ABRELATEDNAMES into X-* fields for better compatibility | |
527 $vcard = preg_replace_callback( | |
528 '/item(\d+)\.(X-ABRELATEDNAMES)([^:]*?):(.*?)item\1.X-ABLabel:(?:_\$!<)?([\w-() ]*)(?:>!\$_)?./s', | |
529 array('self', 'x_abrelatednames_callback'), | |
530 $vcard); | |
531 | |
532 // Cleanup | |
533 $vcard = preg_replace(array( | |
534 // convert special types (like Skype) to normal type='skype' classes with this simple regex ;) | |
535 '/item(\d+)\.(TEL|EMAIL|URL)([^:]*?):(.*?)item\1.X-ABLabel:(?:_\$!<)?([\w-() ]*)(?:>!\$_)?./si', | |
536 '/^item\d*\.X-AB.*$/mi', // remove cruft like item1.X-AB* | |
537 '/^item\d*\./mi', // remove item1.ADR instead of ADR | |
538 '/\n+/', // remove empty lines | |
539 '/^(N:[^;\R]*)$/m', // if N doesn't have any semicolons, add some | |
540 ), | |
541 array( | |
542 '\2;type=\5\3:\4', | |
543 '', | |
544 '', | |
545 "\n", | |
546 '\1;;;;', | |
547 ), $vcard); | |
548 | |
549 // convert X-WAB-GENDER to X-GENDER | |
550 if (preg_match('/X-WAB-GENDER:(\d)/', $vcard, $matches)) { | |
551 $value = $matches[1] == '2' ? 'male' : 'female'; | |
552 $vcard = preg_replace('/X-WAB-GENDER:\d/', 'X-GENDER:' . $value, $vcard); | |
553 } | |
554 | |
555 return $vcard; | |
556 } | |
557 | |
558 private static function x_abrelatednames_callback($matches) | |
559 { | |
560 return 'X-' . strtoupper($matches[5]) . $matches[3] . ':'. $matches[4]; | |
561 } | |
562 | |
563 private static function rfc2425_fold_callback($matches) | |
564 { | |
565 // chunk_split string and avoid lines breaking multibyte characters | |
566 $c = 71; | |
567 $out .= substr($matches[1], 0, $c); | |
568 for ($n = $c; $c < strlen($matches[1]); $c++) { | |
569 // break if length > 75 or mutlibyte character starts after position 71 | |
570 if ($n > 75 || ($n > 71 && ord($matches[1][$c]) >> 6 == 3)) { | |
571 $out .= "\r\n "; | |
572 $n = 0; | |
573 } | |
574 $out .= $matches[1][$c]; | |
575 $n++; | |
576 } | |
577 | |
578 return $out; | |
579 } | |
580 | |
581 public static function rfc2425_fold($val) | |
582 { | |
583 return preg_replace_callback('/([^\n]{72,})/', array('self', 'rfc2425_fold_callback'), $val); | |
584 } | |
585 | |
586 /** | |
587 * Decodes a vcard block (vcard 3.0 format, unfolded) | |
588 * into an array structure | |
589 * | |
590 * @param string vCard block to parse | |
591 * | |
592 * @return array Raw data structure | |
593 */ | |
594 private static function vcard_decode($vcard) | |
595 { | |
596 // Perform RFC2425 line unfolding and split lines | |
597 $vcard = preg_replace(array("/\r/", "/\n\s+/"), '', $vcard); | |
598 $lines = explode("\n", $vcard); | |
599 $result = array(); | |
600 | |
601 for ($i=0; $i < count($lines); $i++) { | |
602 if (!($pos = strpos($lines[$i], ':'))) { | |
603 continue; | |
604 } | |
605 | |
606 $prefix = substr($lines[$i], 0, $pos); | |
607 $data = substr($lines[$i], $pos+1); | |
608 | |
609 if (preg_match('/^(BEGIN|END)$/i', $prefix)) { | |
610 continue; | |
611 } | |
612 | |
613 // convert 2.1-style "EMAIL;internet;home:" to 3.0-style "EMAIL;TYPE=internet;TYPE=home:" | |
614 if ($result['VERSION'][0] == "2.1" | |
615 && preg_match('/^([^;]+);([^:]+)/', $prefix, $regs2) | |
616 && !preg_match('/^TYPE=/i', $regs2[2]) | |
617 ) { | |
618 $prefix = $regs2[1]; | |
619 foreach (explode(';', $regs2[2]) as $prop) { | |
620 $prefix .= ';' . (strpos($prop, '=') ? $prop : 'TYPE='.$prop); | |
621 } | |
622 } | |
623 | |
624 if (preg_match_all('/([^\\;]+);?/', $prefix, $regs2)) { | |
625 $entry = array(); | |
626 $field = strtoupper($regs2[1][0]); | |
627 $enc = null; | |
628 | |
629 foreach ($regs2[1] as $attrid => $attr) { | |
630 $attr = preg_replace('/[\s\t\n\r\0\x0B]/', '', $attr); | |
631 if ((list($key, $value) = explode('=', $attr)) && $value) { | |
632 if ($key == 'ENCODING') { | |
633 $value = strtoupper($value); | |
634 // add next line(s) to value string if QP line end detected | |
635 if ($value == 'QUOTED-PRINTABLE') { | |
636 while (preg_match('/=$/', $lines[$i])) { | |
637 $data .= "\n" . $lines[++$i]; | |
638 } | |
639 } | |
640 $enc = $value == 'BASE64' ? 'B' : $value; | |
641 } | |
642 else { | |
643 $lc_key = strtolower($key); | |
644 $entry[$lc_key] = array_merge((array)$entry[$lc_key], (array)self::vcard_unquote($value, ',')); | |
645 } | |
646 } | |
647 else if ($attrid > 0) { | |
648 $entry[strtolower($key)] = true; // true means attr without =value | |
649 } | |
650 } | |
651 | |
652 // decode value | |
653 if ($enc || !empty($entry['base64'])) { | |
654 // save encoding type (#1488432) | |
655 if ($enc == 'B') { | |
656 $entry['encoding'] = 'B'; | |
657 // should we use vCard 3.0 instead? | |
658 // $entry['base64'] = true; | |
659 } | |
660 | |
661 $data = self::decode_value($data, $enc ?: 'base64'); | |
662 } | |
663 else if ($field == 'PHOTO') { | |
664 // vCard 4.0 data URI, "PHOTO:data:image/jpeg;base64,..." | |
665 if (preg_match('/^data:[a-z\/_-]+;base64,/i', $data, $m)) { | |
666 $entry['encoding'] = $enc = 'B'; | |
667 $data = substr($data, strlen($m[0])); | |
668 $data = self::decode_value($data, 'base64'); | |
669 } | |
670 } | |
671 | |
672 if ($enc != 'B' && empty($entry['base64'])) { | |
673 $data = self::vcard_unquote($data); | |
674 } | |
675 | |
676 $entry = array_merge($entry, (array) $data); | |
677 $result[$field][] = $entry; | |
678 } | |
679 } | |
680 | |
681 unset($result['VERSION']); | |
682 | |
683 return $result; | |
684 } | |
685 | |
686 /** | |
687 * Decode a given string with the encoding rule from ENCODING attributes | |
688 * | |
689 * @param string String to decode | |
690 * @param string Encoding type (quoted-printable and base64 supported) | |
691 * | |
692 * @return string Decoded 8bit value | |
693 */ | |
694 private static function decode_value($value, $encoding) | |
695 { | |
696 switch (strtolower($encoding)) { | |
697 case 'quoted-printable': | |
698 self::$values_decoded = true; | |
699 return quoted_printable_decode($value); | |
700 | |
701 case 'base64': | |
702 case 'b': | |
703 self::$values_decoded = true; | |
704 return base64_decode($value); | |
705 | |
706 default: | |
707 return $value; | |
708 } | |
709 } | |
710 | |
711 /** | |
712 * Encodes an entry for storage in our database (vcard 3.0 format, unfolded) | |
713 * | |
714 * @param array Raw data structure to encode | |
715 * | |
716 * @return string vCard encoded string | |
717 */ | |
718 static function vcard_encode($data) | |
719 { | |
720 foreach ((array)$data as $type => $entries) { | |
721 // valid N has 5 properties | |
722 while ($type == "N" && is_array($entries[0]) && count($entries[0]) < 5) { | |
723 $entries[0][] = ""; | |
724 } | |
725 | |
726 // make sure FN is not empty (required by RFC2426) | |
727 if ($type == "FN" && empty($entries)) { | |
728 $entries[0] = $data['EMAIL'][0][0]; | |
729 } | |
730 | |
731 foreach ((array)$entries as $entry) { | |
732 $attr = ''; | |
733 if (is_array($entry)) { | |
734 $value = array(); | |
735 foreach ($entry as $attrname => $attrvalues) { | |
736 if (is_int($attrname)) { | |
737 if (!empty($entry['base64']) || $entry['encoding'] == 'B') { | |
738 $attrvalues = base64_encode($attrvalues); | |
739 } | |
740 $value[] = $attrvalues; | |
741 } | |
742 else if (is_bool($attrvalues)) { | |
743 // true means just a tag, not tag=value, as in PHOTO;BASE64:... | |
744 if ($attrvalues) { | |
745 // vCard v3 uses ENCODING=b (#1489183) | |
746 if ($attrname == 'base64') { | |
747 $attr .= ";ENCODING=b"; | |
748 } | |
749 else { | |
750 $attr .= strtoupper(";$attrname"); | |
751 } | |
752 } | |
753 } | |
754 else { | |
755 foreach ((array)$attrvalues as $attrvalue) { | |
756 $attr .= strtoupper(";$attrname=") . self::vcard_quote($attrvalue, ','); | |
757 } | |
758 } | |
759 } | |
760 } | |
761 else { | |
762 $value = $entry; | |
763 } | |
764 | |
765 // skip empty entries | |
766 if (self::is_empty($value)) { | |
767 continue; | |
768 } | |
769 | |
770 $vcard .= self::vcard_quote($type) . $attr . ':' . self::vcard_quote($value) . self::$eol; | |
771 } | |
772 } | |
773 | |
774 return 'BEGIN:VCARD' . self::$eol . 'VERSION:3.0' . self::$eol . $vcard . 'END:VCARD'; | |
775 } | |
776 | |
777 /** | |
778 * Join indexed data array to a vcard quoted string | |
779 * | |
780 * @param array Field data | |
781 * @param string Separator | |
782 * | |
783 * @return string Joined and quoted string | |
784 */ | |
785 public static function vcard_quote($s, $sep = ';') | |
786 { | |
787 if (is_array($s)) { | |
788 foreach ($s as $part) { | |
789 $r[] = self::vcard_quote($part, $sep); | |
790 } | |
791 return(implode($sep, (array)$r)); | |
792 } | |
793 | |
794 return strtr($s, array('\\' => '\\\\', "\r" => '', "\n" => '\n', $sep => '\\'.$sep)); | |
795 } | |
796 | |
797 /** | |
798 * Split quoted string | |
799 * | |
800 * @param string vCard string to split | |
801 * @param string Separator char/string | |
802 * | |
803 * @return array List with splited values | |
804 */ | |
805 private static function vcard_unquote($s, $sep = ';') | |
806 { | |
807 // break string into parts separated by $sep | |
808 if (!empty($sep)) { | |
809 // Handle properly backslash escaping (#1488896) | |
810 $rep1 = array("\\\\" => "\010", "\\$sep" => "\007"); | |
811 $rep2 = array("\007" => "\\$sep", "\010" => "\\\\"); | |
812 | |
813 if (count($parts = explode($sep, strtr($s, $rep1))) > 1) { | |
814 foreach ($parts as $s) { | |
815 $result[] = self::vcard_unquote(strtr($s, $rep2)); | |
816 } | |
817 return $result; | |
818 } | |
819 | |
820 $s = trim(strtr($s, $rep2)); | |
821 } | |
822 | |
823 // some implementations (GMail) use non-standard backslash before colon (#1489085) | |
824 // we will handle properly any backslashed character - removing dummy backslahes | |
825 // return strtr($s, array("\r" => '', '\\\\' => '\\', '\n' => "\n", '\N' => "\n", '\,' => ',', '\;' => ';')); | |
826 | |
827 $s = str_replace("\r", '', $s); | |
828 $pos = 0; | |
829 | |
830 while (($pos = strpos($s, '\\', $pos)) !== false) { | |
831 $next = substr($s, $pos + 1, 1); | |
832 if ($next == 'n' || $next == 'N') { | |
833 $s = substr_replace($s, "\n", $pos, 2); | |
834 } | |
835 else { | |
836 $s = substr_replace($s, '', $pos, 1); | |
837 } | |
838 | |
839 $pos += 1; | |
840 } | |
841 | |
842 return $s; | |
843 } | |
844 | |
845 /** | |
846 * Check if vCard entry is empty: empty string or an array with | |
847 * all entries empty. | |
848 * | |
849 * @param mixed $value Attribute value (string or array) | |
850 * | |
851 * @return bool True if the value is empty, False otherwise | |
852 */ | |
853 private static function is_empty($value) | |
854 { | |
855 foreach ((array)$value as $v) { | |
856 if (((string)$v) !== '') { | |
857 return false; | |
858 } | |
859 } | |
860 | |
861 return true; | |
862 } | |
863 | |
864 /** | |
865 * Extract array values by a filter | |
866 * | |
867 * @param array Array to filter | |
868 * @param keys Array or comma separated list of values to keep | |
869 * @param boolean Invert key selection: remove the listed values | |
870 * | |
871 * @return array The filtered array | |
872 */ | |
873 private static function array_filter($arr, $values, $inverse = false) | |
874 { | |
875 if (!is_array($values)) { | |
876 $values = explode(',', $values); | |
877 } | |
878 | |
879 $result = array(); | |
880 $keep = array_flip((array)$values); | |
881 | |
882 foreach ($arr as $key => $val) { | |
883 if ($inverse != isset($keep[strtolower($val)])) { | |
884 $result[$key] = $val; | |
885 } | |
886 } | |
887 | |
888 return $result; | |
889 } | |
890 | |
891 /** | |
892 * Returns UNICODE type based on BOM (Byte Order Mark) | |
893 * | |
894 * @param string Input string to test | |
895 * | |
896 * @return string Detected encoding | |
897 */ | |
898 private static function detect_encoding($string) | |
899 { | |
900 $fallback = rcube::get_instance()->config->get('default_charset', 'ISO-8859-1'); // fallback to Latin-1 | |
901 | |
902 return rcube_charset::detect($string, $fallback); | |
903 } | |
904 } |