1<?php
2
3namespace Sabre\VObject;
4
5use Sabre\Xml;
6
7/**
8 * Property.
9 *
10 * A property is always in a KEY:VALUE structure, and may optionally contain
11 * parameters.
12 *
13 * @copyright Copyright (C) fruux GmbH (https://fruux.com/)
14 * @author Evert Pot (http://evertpot.com/)
15 * @license http://sabre.io/license/ Modified BSD License
16 */
17abstract class Property extends Node
18{
19    /**
20     * Property name.
21     *
22     * This will contain a string such as DTSTART, SUMMARY, FN.
23     *
24     * @var string
25     */
26    public $name;
27
28    /**
29     * Property group.
30     *
31     * This is only used in vcards
32     *
33     * @var string
34     */
35    public $group;
36
37    /**
38     * List of parameters.
39     *
40     * @var array
41     */
42    public $parameters = [];
43
44    /**
45     * Current value.
46     *
47     * @var mixed
48     */
49    protected $value;
50
51    /**
52     * In case this is a multi-value property. This string will be used as a
53     * delimiter.
54     *
55     * @var string|null
56     */
57    public $delimiter = ';';
58
59    /**
60     * Creates the generic property.
61     *
62     * Parameters must be specified in key=>value syntax.
63     *
64     * @param Component         $root       The root document
65     * @param string            $name
66     * @param string|array|null $value
67     * @param array             $parameters List of parameters
68     * @param string            $group      The vcard property group
69     */
70    public function __construct(Component $root, $name, $value = null, array $parameters = [], $group = null)
71    {
72        $this->name = $name;
73        $this->group = $group;
74
75        $this->root = $root;
76
77        foreach ($parameters as $k => $v) {
78            $this->add($k, $v);
79        }
80
81        if (!is_null($value)) {
82            $this->setValue($value);
83        }
84    }
85
86    /**
87     * Updates the current value.
88     *
89     * This may be either a single, or multiple strings in an array.
90     *
91     * @param string|array $value
92     */
93    public function setValue($value)
94    {
95        $this->value = $value;
96    }
97
98    /**
99     * Returns the current value.
100     *
101     * This method will always return a singular value. If this was a
102     * multi-value object, some decision will be made first on how to represent
103     * it as a string.
104     *
105     * To get the correct multi-value version, use getParts.
106     *
107     * @return string
108     */
109    public function getValue()
110    {
111        if (is_array($this->value)) {
112            if (0 == count($this->value)) {
113                return;
114            } elseif (1 === count($this->value)) {
115                return $this->value[0];
116            } else {
117                return $this->getRawMimeDirValue();
118            }
119        } else {
120            return $this->value;
121        }
122    }
123
124    /**
125     * Sets a multi-valued property.
126     *
127     * @param array $parts
128     */
129    public function setParts(array $parts)
130    {
131        $this->value = $parts;
132    }
133
134    /**
135     * Returns a multi-valued property.
136     *
137     * This method always returns an array, if there was only a single value,
138     * it will still be wrapped in an array.
139     *
140     * @return array
141     */
142    public function getParts()
143    {
144        if (is_null($this->value)) {
145            return [];
146        } elseif (is_array($this->value)) {
147            return $this->value;
148        } else {
149            return [$this->value];
150        }
151    }
152
153    /**
154     * Adds a new parameter.
155     *
156     * If a parameter with same name already existed, the values will be
157     * combined.
158     * If nameless parameter is added, we try to guess its name.
159     *
160     * @param string            $name
161     * @param string|array|null $value
162     */
163    public function add($name, $value = null)
164    {
165        $noName = false;
166        if (null === $name) {
167            $name = Parameter::guessParameterNameByValue($value);
168            $noName = true;
169        }
170
171        if (isset($this->parameters[strtoupper($name)])) {
172            $this->parameters[strtoupper($name)]->addValue($value);
173        } else {
174            $param = new Parameter($this->root, $name, $value);
175            $param->noName = $noName;
176            $this->parameters[$param->name] = $param;
177        }
178    }
179
180    /**
181     * Returns an iterable list of children.
182     *
183     * @return array
184     */
185    public function parameters()
186    {
187        return $this->parameters;
188    }
189
190    /**
191     * Returns the type of value.
192     *
193     * This corresponds to the VALUE= parameter. Every property also has a
194     * 'default' valueType.
195     *
196     * @return string
197     */
198    abstract public function getValueType();
199
200    /**
201     * Sets a raw value coming from a mimedir (iCalendar/vCard) file.
202     *
203     * This has been 'unfolded', so only 1 line will be passed. Unescaping is
204     * not yet done, but parameters are not included.
205     *
206     * @param string $val
207     */
208    abstract public function setRawMimeDirValue($val);
209
210    /**
211     * Returns a raw mime-dir representation of the value.
212     *
213     * @return string
214     */
215    abstract public function getRawMimeDirValue();
216
217    /**
218     * Turns the object back into a serialized blob.
219     *
220     * @return string
221     */
222    public function serialize()
223    {
224        $str = $this->name;
225        if ($this->group) {
226            $str = $this->group.'.'.$this->name;
227        }
228
229        foreach ($this->parameters() as $param) {
230            $str .= ';'.$param->serialize();
231        }
232
233        $str .= ':'.$this->getRawMimeDirValue();
234
235        $str = \preg_replace(
236            '/(
237                (?:^.)?         # 1 additional byte in first line because of missing single space (see next line)
238                .{1,74}         # max 75 bytes per line (1 byte is used for a single space added after every CRLF)
239                (?![\x80-\xbf]) # prevent splitting multibyte characters
240            )/x',
241            "$1\r\n ",
242            $str
243        );
244
245        // remove single space after last CRLF
246        return \substr($str, 0, -1);
247    }
248
249    /**
250     * Returns the value, in the format it should be encoded for JSON.
251     *
252     * This method must always return an array.
253     *
254     * @return array
255     */
256    public function getJsonValue()
257    {
258        return $this->getParts();
259    }
260
261    /**
262     * Sets the JSON value, as it would appear in a jCard or jCal object.
263     *
264     * The value must always be an array.
265     *
266     * @param array $value
267     */
268    public function setJsonValue(array $value)
269    {
270        if (1 === count($value)) {
271            $this->setValue(reset($value));
272        } else {
273            $this->setValue($value);
274        }
275    }
276
277    /**
278     * This method returns an array, with the representation as it should be
279     * encoded in JSON. This is used to create jCard or jCal documents.
280     *
281     * @return array
282     */
283    public function jsonSerialize()
284    {
285        $parameters = [];
286
287        foreach ($this->parameters as $parameter) {
288            if ('VALUE' === $parameter->name) {
289                continue;
290            }
291            $parameters[strtolower($parameter->name)] = $parameter->jsonSerialize();
292        }
293        // In jCard, we need to encode the property-group as a separate 'group'
294        // parameter.
295        if ($this->group) {
296            $parameters['group'] = $this->group;
297        }
298
299        return array_merge(
300            [
301                strtolower($this->name),
302                (object) $parameters,
303                strtolower($this->getValueType()),
304            ],
305            $this->getJsonValue()
306        );
307    }
308
309    /**
310     * Hydrate data from a XML subtree, as it would appear in a xCard or xCal
311     * object.
312     *
313     * @param array $value
314     */
315    public function setXmlValue(array $value)
316    {
317        $this->setJsonValue($value);
318    }
319
320    /**
321     * This method serializes the data into XML. This is used to create xCard or
322     * xCal documents.
323     *
324     * @param Xml\Writer $writer XML writer
325     */
326    public function xmlSerialize(Xml\Writer $writer)
327    {
328        $parameters = [];
329
330        foreach ($this->parameters as $parameter) {
331            if ('VALUE' === $parameter->name) {
332                continue;
333            }
334
335            $parameters[] = $parameter;
336        }
337
338        $writer->startElement(strtolower($this->name));
339
340        if (!empty($parameters)) {
341            $writer->startElement('parameters');
342
343            foreach ($parameters as $parameter) {
344                $writer->startElement(strtolower($parameter->name));
345                $writer->write($parameter);
346                $writer->endElement();
347            }
348
349            $writer->endElement();
350        }
351
352        $this->xmlSerializeValue($writer);
353        $writer->endElement();
354    }
355
356    /**
357     * This method serializes only the value of a property. This is used to
358     * create xCard or xCal documents.
359     *
360     * @param Xml\Writer $writer XML writer
361     */
362    protected function xmlSerializeValue(Xml\Writer $writer)
363    {
364        $valueType = strtolower($this->getValueType());
365
366        foreach ($this->getJsonValue() as $values) {
367            foreach ((array) $values as $value) {
368                $writer->writeElement($valueType, $value);
369            }
370        }
371    }
372
373    /**
374     * Called when this object is being cast to a string.
375     *
376     * If the property only had a single value, you will get just that. In the
377     * case the property had multiple values, the contents will be escaped and
378     * combined with ,.
379     *
380     * @return string
381     */
382    public function __toString()
383    {
384        return (string) $this->getValue();
385    }
386
387    /* ArrayAccess interface {{{ */
388
389    /**
390     * Checks if an array element exists.
391     *
392     * @param mixed $name
393     *
394     * @return bool
395     */
396    public function offsetExists($name)
397    {
398        if (is_int($name)) {
399            return parent::offsetExists($name);
400        }
401
402        $name = strtoupper($name);
403
404        foreach ($this->parameters as $parameter) {
405            if ($parameter->name == $name) {
406                return true;
407            }
408        }
409
410        return false;
411    }
412
413    /**
414     * Returns a parameter.
415     *
416     * If the parameter does not exist, null is returned.
417     *
418     * @param string $name
419     *
420     * @return Node
421     */
422    public function offsetGet($name)
423    {
424        if (is_int($name)) {
425            return parent::offsetGet($name);
426        }
427        $name = strtoupper($name);
428
429        if (!isset($this->parameters[$name])) {
430            return;
431        }
432
433        return $this->parameters[$name];
434    }
435
436    /**
437     * Creates a new parameter.
438     *
439     * @param string $name
440     * @param mixed  $value
441     */
442    public function offsetSet($name, $value)
443    {
444        if (is_int($name)) {
445            parent::offsetSet($name, $value);
446            // @codeCoverageIgnoreStart
447            // This will never be reached, because an exception is always
448            // thrown.
449            return;
450            // @codeCoverageIgnoreEnd
451        }
452
453        $param = new Parameter($this->root, $name, $value);
454        $this->parameters[$param->name] = $param;
455    }
456
457    /**
458     * Removes one or more parameters with the specified name.
459     *
460     * @param string $name
461     */
462    public function offsetUnset($name)
463    {
464        if (is_int($name)) {
465            parent::offsetUnset($name);
466            // @codeCoverageIgnoreStart
467            // This will never be reached, because an exception is always
468            // thrown.
469            return;
470            // @codeCoverageIgnoreEnd
471        }
472
473        unset($this->parameters[strtoupper($name)]);
474    }
475
476    /* }}} */
477
478    /**
479     * This method is automatically called when the object is cloned.
480     * Specifically, this will ensure all child elements are also cloned.
481     */
482    public function __clone()
483    {
484        foreach ($this->parameters as $key => $child) {
485            $this->parameters[$key] = clone $child;
486            $this->parameters[$key]->parent = $this;
487        }
488    }
489
490    /**
491     * Validates the node for correctness.
492     *
493     * The following options are supported:
494     *   - Node::REPAIR - If something is broken, and automatic repair may
495     *                    be attempted.
496     *
497     * An array is returned with warnings.
498     *
499     * Every item in the array has the following properties:
500     *    * level - (number between 1 and 3 with severity information)
501     *    * message - (human readable message)
502     *    * node - (reference to the offending node)
503     *
504     * @param int $options
505     *
506     * @return array
507     */
508    public function validate($options = 0)
509    {
510        $warnings = [];
511
512        // Checking if our value is UTF-8
513        if (!StringUtil::isUTF8($this->getRawMimeDirValue())) {
514            $oldValue = $this->getRawMimeDirValue();
515            $level = 3;
516            if ($options & self::REPAIR) {
517                $newValue = StringUtil::convertToUTF8($oldValue);
518                if (true || StringUtil::isUTF8($newValue)) {
519                    $this->setRawMimeDirValue($newValue);
520                    $level = 1;
521                }
522            }
523
524            if (preg_match('%([\x00-\x08\x0B-\x0C\x0E-\x1F\x7F])%', $oldValue, $matches)) {
525                $message = 'Property contained a control character (0x'.bin2hex($matches[1]).')';
526            } else {
527                $message = 'Property is not valid UTF-8! '.$oldValue;
528            }
529
530            $warnings[] = [
531                'level' => $level,
532                'message' => $message,
533                'node' => $this,
534            ];
535        }
536
537        // Checking if the propertyname does not contain any invalid bytes.
538        if (!preg_match('/^([A-Z0-9-]+)$/', $this->name)) {
539            $warnings[] = [
540                'level' => $options & self::REPAIR ? 1 : 3,
541                'message' => 'The propertyname: '.$this->name.' contains invalid characters. Only A-Z, 0-9 and - are allowed',
542                'node' => $this,
543            ];
544            if ($options & self::REPAIR) {
545                // Uppercasing and converting underscores to dashes.
546                $this->name = strtoupper(
547                    str_replace('_', '-', $this->name)
548                );
549                // Removing every other invalid character
550                $this->name = preg_replace('/([^A-Z0-9-])/u', '', $this->name);
551            }
552        }
553
554        if ($encoding = $this->offsetGet('ENCODING')) {
555            if (Document::VCARD40 === $this->root->getDocumentType()) {
556                $warnings[] = [
557                    'level' => 3,
558                    'message' => 'ENCODING parameter is not valid in vCard 4.',
559                    'node' => $this,
560                ];
561            } else {
562                $encoding = (string) $encoding;
563
564                $allowedEncoding = [];
565
566                switch ($this->root->getDocumentType()) {
567                    case Document::ICALENDAR20:
568                        $allowedEncoding = ['8BIT', 'BASE64'];
569                        break;
570                    case Document::VCARD21:
571                        $allowedEncoding = ['QUOTED-PRINTABLE', 'BASE64', '8BIT'];
572                        break;
573                    case Document::VCARD30:
574                        $allowedEncoding = ['B'];
575                        //Repair vCard30 that use BASE64 encoding
576                        if ($options & self::REPAIR) {
577                            if ('BASE64' === strtoupper($encoding)) {
578                                $encoding = 'B';
579                                $this['ENCODING'] = $encoding;
580                                $warnings[] = [
581                                    'level' => 1,
582                                    'message' => 'ENCODING=BASE64 has been transformed to ENCODING=B.',
583                                    'node' => $this,
584                                ];
585                            }
586                        }
587                        break;
588                }
589                if ($allowedEncoding && !in_array(strtoupper($encoding), $allowedEncoding)) {
590                    $warnings[] = [
591                        'level' => 3,
592                        'message' => 'ENCODING='.strtoupper($encoding).' is not valid for this document type.',
593                        'node' => $this,
594                    ];
595                }
596            }
597        }
598
599        // Validating inner parameters
600        foreach ($this->parameters as $param) {
601            $warnings = array_merge($warnings, $param->validate($options));
602        }
603
604        return $warnings;
605    }
606
607    /**
608     * Call this method on a document if you're done using it.
609     *
610     * It's intended to remove all circular references, so PHP can easily clean
611     * it up.
612     */
613    public function destroy()
614    {
615        parent::destroy();
616        foreach ($this->parameters as $param) {
617            $param->destroy();
618        }
619        $this->parameters = [];
620    }
621}
622