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