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