1<?php
2
3namespace Sabre\VObject;
4
5/**
6 * This utility converts vcards from one version to another.
7 *
8 * @copyright Copyright (C) fruux GmbH (https://fruux.com/)
9 * @author Evert Pot (http://evertpot.com/)
10 * @license http://sabre.io/license/ Modified BSD License
11 */
12class VCardConverter
13{
14    /**
15     * Converts a vCard object to a new version.
16     *
17     * targetVersion must be one of:
18     *   Document::VCARD21
19     *   Document::VCARD30
20     *   Document::VCARD40
21     *
22     * Currently only 3.0 and 4.0 as input and output versions.
23     *
24     * 2.1 has some minor support for the input version, it's incomplete at the
25     * moment though.
26     *
27     * If input and output version are identical, a clone is returned.
28     *
29     * @param Component\VCard $input
30     * @param int             $targetVersion
31     */
32    public function convert(Component\VCard $input, $targetVersion)
33    {
34        $inputVersion = $input->getDocumentType();
35        if ($inputVersion === $targetVersion) {
36            return clone $input;
37        }
38
39        if (!in_array($inputVersion, [Document::VCARD21, Document::VCARD30, Document::VCARD40])) {
40            throw new \InvalidArgumentException('Only vCard 2.1, 3.0 and 4.0 are supported for the input data');
41        }
42        if (!in_array($targetVersion, [Document::VCARD30, Document::VCARD40])) {
43            throw new \InvalidArgumentException('You can only use vCard 3.0 or 4.0 for the target version');
44        }
45
46        $newVersion = Document::VCARD40 === $targetVersion ? '4.0' : '3.0';
47
48        $output = new Component\VCard([
49            'VERSION' => $newVersion,
50        ]);
51
52        // We might have generated a default UID. Remove it!
53        unset($output->UID);
54
55        foreach ($input->children() as $property) {
56            $this->convertProperty($input, $output, $property, $targetVersion);
57        }
58
59        return $output;
60    }
61
62    /**
63     * Handles conversion of a single property.
64     *
65     * @param Component\VCard $input
66     * @param Component\VCard $output
67     * @param Property        $property
68     * @param int             $targetVersion
69     */
70    protected function convertProperty(Component\VCard $input, Component\VCard $output, Property $property, $targetVersion)
71    {
72        // Skipping these, those are automatically added.
73        if (in_array($property->name, ['VERSION', 'PRODID'])) {
74            return;
75        }
76
77        $parameters = $property->parameters();
78        $valueType = null;
79        if (isset($parameters['VALUE'])) {
80            $valueType = $parameters['VALUE']->getValue();
81            unset($parameters['VALUE']);
82        }
83        if (!$valueType) {
84            $valueType = $property->getValueType();
85        }
86        if (Document::VCARD30 !== $targetVersion && 'PHONE-NUMBER' === $valueType) {
87            $valueType = null;
88        }
89        $newProperty = $output->createProperty(
90            $property->name,
91            $property->getParts(),
92            [], // parameters will get added a bit later.
93            $valueType
94        );
95
96        if (Document::VCARD30 === $targetVersion) {
97            if ($property instanceof Property\Uri && in_array($property->name, ['PHOTO', 'LOGO', 'SOUND'])) {
98                $newProperty = $this->convertUriToBinary($output, $newProperty);
99            } elseif ($property instanceof Property\VCard\DateAndOrTime) {
100                // In vCard 4, the birth year may be optional. This is not the
101                // case for vCard 3. Apple has a workaround for this that
102                // allows applications that support Apple's extension still
103                // omit birthyears in vCard 3, but applications that do not
104                // support this, will just use a random birthyear. We're
105                // choosing 1604 for the birthyear, because that's what apple
106                // uses.
107                $parts = DateTimeParser::parseVCardDateTime($property->getValue());
108                if (is_null($parts['year'])) {
109                    $newValue = '1604-'.$parts['month'].'-'.$parts['date'];
110                    $newProperty->setValue($newValue);
111                    $newProperty['X-APPLE-OMIT-YEAR'] = '1604';
112                }
113
114                if ('ANNIVERSARY' == $newProperty->name) {
115                    // Microsoft non-standard anniversary
116                    $newProperty->name = 'X-ANNIVERSARY';
117
118                    // We also need to add a new apple property for the same
119                    // purpose. This apple property needs a 'label' in the same
120                    // group, so we first need to find a groupname that doesn't
121                    // exist yet.
122                    $x = 1;
123                    while ($output->select('ITEM'.$x.'.')) {
124                        ++$x;
125                    }
126                    $output->add('ITEM'.$x.'.X-ABDATE', $newProperty->getValue(), ['VALUE' => 'DATE-AND-OR-TIME']);
127                    $output->add('ITEM'.$x.'.X-ABLABEL', '_$!<Anniversary>!$_');
128                }
129            } elseif ('KIND' === $property->name) {
130                switch (strtolower($property->getValue())) {
131                    case 'org':
132                        // vCard 3.0 does not have an equivalent to KIND:ORG,
133                        // but apple has an extension that means the same
134                        // thing.
135                        $newProperty = $output->createProperty('X-ABSHOWAS', 'COMPANY');
136                        break;
137
138                    case 'individual':
139                        // Individual is implicit, so we skip it.
140                        return;
141
142                    case 'group':
143                        // OS X addressbook property
144                        $newProperty = $output->createProperty('X-ADDRESSBOOKSERVER-KIND', 'GROUP');
145                        break;
146                }
147            }
148        } elseif (Document::VCARD40 === $targetVersion) {
149            // These properties were removed in vCard 4.0
150            if (in_array($property->name, ['NAME', 'MAILER', 'LABEL', 'CLASS'])) {
151                return;
152            }
153
154            if ($property instanceof Property\Binary) {
155                $newProperty = $this->convertBinaryToUri($output, $newProperty, $parameters);
156            } elseif ($property instanceof Property\VCard\DateAndOrTime && isset($parameters['X-APPLE-OMIT-YEAR'])) {
157                // If a property such as BDAY contained 'X-APPLE-OMIT-YEAR',
158                // then we're stripping the year from the vcard 4 value.
159                $parts = DateTimeParser::parseVCardDateTime($property->getValue());
160                if ($parts['year'] === $property['X-APPLE-OMIT-YEAR']->getValue()) {
161                    $newValue = '--'.$parts['month'].'-'.$parts['date'];
162                    $newProperty->setValue($newValue);
163                }
164
165                // Regardless if the year matched or not, we do need to strip
166                // X-APPLE-OMIT-YEAR.
167                unset($parameters['X-APPLE-OMIT-YEAR']);
168            }
169            switch ($property->name) {
170                case 'X-ABSHOWAS':
171                    if ('COMPANY' === strtoupper($property->getValue())) {
172                        $newProperty = $output->createProperty('KIND', 'ORG');
173                    }
174                    break;
175                case 'X-ADDRESSBOOKSERVER-KIND':
176                    if ('GROUP' === strtoupper($property->getValue())) {
177                        $newProperty = $output->createProperty('KIND', 'GROUP');
178                    }
179                    break;
180                case 'X-ANNIVERSARY':
181                    $newProperty->name = 'ANNIVERSARY';
182                    // If we already have an anniversary property with the same
183                    // value, ignore.
184                    foreach ($output->select('ANNIVERSARY') as $anniversary) {
185                        if ($anniversary->getValue() === $newProperty->getValue()) {
186                            return;
187                        }
188                    }
189                    break;
190                case 'X-ABDATE':
191                    // Find out what the label was, if it exists.
192                    if (!$property->group) {
193                        break;
194                    }
195                    $label = $input->{$property->group.'.X-ABLABEL'};
196
197                    // We only support converting anniversaries.
198                    if (!$label || '_$!<Anniversary>!$_' !== $label->getValue()) {
199                        break;
200                    }
201
202                    // If we already have an anniversary property with the same
203                    // value, ignore.
204                    foreach ($output->select('ANNIVERSARY') as $anniversary) {
205                        if ($anniversary->getValue() === $newProperty->getValue()) {
206                            return;
207                        }
208                    }
209                    $newProperty->name = 'ANNIVERSARY';
210                    break;
211                // Apple's per-property label system.
212                case 'X-ABLABEL':
213                    if ('_$!<Anniversary>!$_' === $newProperty->getValue()) {
214                        // We can safely remove these, as they are converted to
215                        // ANNIVERSARY properties.
216                        return;
217                    }
218                    break;
219            }
220        }
221
222        // set property group
223        $newProperty->group = $property->group;
224
225        if (Document::VCARD40 === $targetVersion) {
226            $this->convertParameters40($newProperty, $parameters);
227        } else {
228            $this->convertParameters30($newProperty, $parameters);
229        }
230
231        // Lastly, we need to see if there's a need for a VALUE parameter.
232        //
233        // We can do that by instantiating a empty property with that name, and
234        // seeing if the default valueType is identical to the current one.
235        $tempProperty = $output->createProperty($newProperty->name);
236        if ($tempProperty->getValueType() !== $newProperty->getValueType()) {
237            $newProperty['VALUE'] = $newProperty->getValueType();
238        }
239
240        $output->add($newProperty);
241    }
242
243    /**
244     * Converts a BINARY property to a URI property.
245     *
246     * vCard 4.0 no longer supports BINARY properties.
247     *
248     * @param Component\VCard $output
249     * @param Property\Uri    $property the input property
250     * @param $parameters list of parameters that will eventually be added to
251     *                    the new property
252     *
253     * @return Property\Uri
254     */
255    protected function convertBinaryToUri(Component\VCard $output, Property\Binary $newProperty, array &$parameters)
256    {
257        $value = $newProperty->getValue();
258        $newProperty = $output->createProperty(
259            $newProperty->name,
260            null, // no value
261            [], // no parameters yet
262            'URI' // Forcing the BINARY type
263        );
264
265        $mimeType = 'application/octet-stream';
266
267        // See if we can find a better mimetype.
268        if (isset($parameters['TYPE'])) {
269            $newTypes = [];
270            foreach ($parameters['TYPE']->getParts() as $typePart) {
271                if (in_array(
272                    strtoupper($typePart),
273                    ['JPEG', 'PNG', 'GIF']
274                )) {
275                    $mimeType = 'image/'.strtolower($typePart);
276                } else {
277                    $newTypes[] = $typePart;
278                }
279            }
280
281            // If there were any parameters we're not converting to a
282            // mime-type, we need to keep them.
283            if ($newTypes) {
284                $parameters['TYPE']->setParts($newTypes);
285            } else {
286                unset($parameters['TYPE']);
287            }
288        }
289
290        $newProperty->setValue('data:'.$mimeType.';base64,'.base64_encode($value));
291
292        return $newProperty;
293    }
294
295    /**
296     * Converts a URI property to a BINARY property.
297     *
298     * In vCard 4.0 attachments are encoded as data: uri. Even though these may
299     * be valid in vCard 3.0 as well, we should convert those to BINARY if
300     * possible, to improve compatibility.
301     *
302     * @param Component\VCard $output
303     * @param Property\Uri    $property the input property
304     *
305     * @return Property\Binary|null
306     */
307    protected function convertUriToBinary(Component\VCard $output, Property\Uri $newProperty)
308    {
309        $value = $newProperty->getValue();
310
311        // Only converting data: uris
312        if ('data:' !== substr($value, 0, 5)) {
313            return $newProperty;
314        }
315
316        $newProperty = $output->createProperty(
317            $newProperty->name,
318            null, // no value
319            [], // no parameters yet
320            'BINARY'
321        );
322
323        $mimeType = substr($value, 5, strpos($value, ',') - 5);
324        if (strpos($mimeType, ';')) {
325            $mimeType = substr($mimeType, 0, strpos($mimeType, ';'));
326            $newProperty->setValue(base64_decode(substr($value, strpos($value, ',') + 1)));
327        } else {
328            $newProperty->setValue(substr($value, strpos($value, ',') + 1));
329        }
330        unset($value);
331
332        $newProperty['ENCODING'] = 'b';
333        switch ($mimeType) {
334            case 'image/jpeg':
335                $newProperty['TYPE'] = 'JPEG';
336                break;
337            case 'image/png':
338                $newProperty['TYPE'] = 'PNG';
339                break;
340            case 'image/gif':
341                $newProperty['TYPE'] = 'GIF';
342                break;
343        }
344
345        return $newProperty;
346    }
347
348    /**
349     * Adds parameters to a new property for vCard 4.0.
350     *
351     * @param Property $newProperty
352     * @param array    $parameters
353     */
354    protected function convertParameters40(Property $newProperty, array $parameters)
355    {
356        // Adding all parameters.
357        foreach ($parameters as $param) {
358            // vCard 2.1 allowed parameters with no name
359            if ($param->noName) {
360                $param->noName = false;
361            }
362
363            switch ($param->name) {
364                // We need to see if there's any TYPE=PREF, because in vCard 4
365                // that's now PREF=1.
366                case 'TYPE':
367                    foreach ($param->getParts() as $paramPart) {
368                        if ('PREF' === strtoupper($paramPart)) {
369                            $newProperty->add('PREF', '1');
370                        } else {
371                            $newProperty->add($param->name, $paramPart);
372                        }
373                    }
374                    break;
375                // These no longer exist in vCard 4
376                case 'ENCODING':
377                case 'CHARSET':
378                    break;
379
380                default:
381                    $newProperty->add($param->name, $param->getParts());
382                    break;
383            }
384        }
385    }
386
387    /**
388     * Adds parameters to a new property for vCard 3.0.
389     *
390     * @param Property $newProperty
391     * @param array    $parameters
392     */
393    protected function convertParameters30(Property $newProperty, array $parameters)
394    {
395        // Adding all parameters.
396        foreach ($parameters as $param) {
397            // vCard 2.1 allowed parameters with no name
398            if ($param->noName) {
399                $param->noName = false;
400            }
401
402            switch ($param->name) {
403                case 'ENCODING':
404                    // This value only existed in vCard 2.1, and should be
405                    // removed for anything else.
406                    if ('QUOTED-PRINTABLE' !== strtoupper($param->getValue())) {
407                        $newProperty->add($param->name, $param->getParts());
408                    }
409                    break;
410
411                /*
412                 * Converting PREF=1 to TYPE=PREF.
413                 *
414                 * Any other PREF numbers we'll drop.
415                 */
416                case 'PREF':
417                    if ('1' == $param->getValue()) {
418                        $newProperty->add('TYPE', 'PREF');
419                    }
420                    break;
421
422                default:
423                    $newProperty->add($param->name, $param->getParts());
424                    break;
425            }
426        }
427    }
428}
429