1<?php
2
3namespace Sabre\VObject\Property\ICalendar;
4
5use DateTimeInterface;
6use DateTimeZone;
7use Sabre\VObject\DateTimeParser;
8use Sabre\VObject\InvalidDataException;
9use Sabre\VObject\Property;
10use Sabre\VObject\TimeZoneUtil;
11
12/**
13 * DateTime property.
14 *
15 * This object represents DATE-TIME values, as defined here:
16 *
17 * http://tools.ietf.org/html/rfc5545#section-3.3.4
18 *
19 * This particular object has a bit of hackish magic that it may also in some
20 * cases represent a DATE value. This is because it's a common usecase to be
21 * able to change a DATE-TIME into a DATE.
22 *
23 * @copyright Copyright (C) fruux GmbH (https://fruux.com/)
24 * @author Evert Pot (http://evertpot.com/)
25 * @license http://sabre.io/license/ Modified BSD License
26 */
27class DateTime extends Property
28{
29    /**
30     * In case this is a multi-value property. This string will be used as a
31     * delimiter.
32     *
33     * @var string|null
34     */
35    public $delimiter = ',';
36
37    /**
38     * Sets a multi-valued property.
39     *
40     * You may also specify DateTime objects here.
41     *
42     * @param array $parts
43     */
44    public function setParts(array $parts)
45    {
46        if (isset($parts[0]) && $parts[0] instanceof DateTimeInterface) {
47            $this->setDateTimes($parts);
48        } else {
49            parent::setParts($parts);
50        }
51    }
52
53    /**
54     * Updates the current value.
55     *
56     * This may be either a single, or multiple strings in an array.
57     *
58     * Instead of strings, you may also use DateTime here.
59     *
60     * @param string|array|DateTimeInterface $value
61     */
62    public function setValue($value)
63    {
64        if (is_array($value) && isset($value[0]) && $value[0] instanceof DateTimeInterface) {
65            $this->setDateTimes($value);
66        } elseif ($value instanceof DateTimeInterface) {
67            $this->setDateTimes([$value]);
68        } else {
69            parent::setValue($value);
70        }
71    }
72
73    /**
74     * Sets a raw value coming from a mimedir (iCalendar/vCard) file.
75     *
76     * This has been 'unfolded', so only 1 line will be passed. Unescaping is
77     * not yet done, but parameters are not included.
78     *
79     * @param string $val
80     */
81    public function setRawMimeDirValue($val)
82    {
83        $this->setValue(explode($this->delimiter, $val));
84    }
85
86    /**
87     * Returns a raw mime-dir representation of the value.
88     *
89     * @return string
90     */
91    public function getRawMimeDirValue()
92    {
93        return implode($this->delimiter, $this->getParts());
94    }
95
96    /**
97     * Returns true if this is a DATE-TIME value, false if it's a DATE.
98     *
99     * @return bool
100     */
101    public function hasTime()
102    {
103        return 'DATE' !== strtoupper((string) $this['VALUE']);
104    }
105
106    /**
107     * Returns true if this is a floating DATE or DATE-TIME.
108     *
109     * Note that DATE is always floating.
110     */
111    public function isFloating()
112    {
113        return
114            !$this->hasTime() ||
115            (
116                !isset($this['TZID']) &&
117                false === strpos($this->getValue(), 'Z')
118            );
119    }
120
121    /**
122     * Returns a date-time value.
123     *
124     * Note that if this property contained more than 1 date-time, only the
125     * first will be returned. To get an array with multiple values, call
126     * getDateTimes.
127     *
128     * If no timezone information is known, because it's either an all-day
129     * property or floating time, we will use the DateTimeZone argument to
130     * figure out the exact date.
131     *
132     * @param DateTimeZone $timeZone
133     *
134     * @return \DateTimeImmutable
135     */
136    public function getDateTime(DateTimeZone $timeZone = null)
137    {
138        $dt = $this->getDateTimes($timeZone);
139        if (!$dt) {
140            return;
141        }
142
143        return $dt[0];
144    }
145
146    /**
147     * Returns multiple date-time values.
148     *
149     * If no timezone information is known, because it's either an all-day
150     * property or floating time, we will use the DateTimeZone argument to
151     * figure out the exact date.
152     *
153     * @param DateTimeZone $timeZone
154     *
155     * @return \DateTimeImmutable[]
156     * @return \DateTime[]
157     */
158    public function getDateTimes(DateTimeZone $timeZone = null)
159    {
160        // Does the property have a TZID?
161        $tzid = $this['TZID'];
162
163        if ($tzid) {
164            $timeZone = TimeZoneUtil::getTimeZone((string) $tzid, $this->root);
165        }
166
167        $dts = [];
168        foreach ($this->getParts() as $part) {
169            $dts[] = DateTimeParser::parse($part, $timeZone);
170        }
171
172        return $dts;
173    }
174
175    /**
176     * Sets the property as a DateTime object.
177     *
178     * @param DateTimeInterface $dt
179     * @param bool isFloating If set to true, timezones will be ignored
180     */
181    public function setDateTime(DateTimeInterface $dt, $isFloating = false)
182    {
183        $this->setDateTimes([$dt], $isFloating);
184    }
185
186    /**
187     * Sets the property as multiple date-time objects.
188     *
189     * The first value will be used as a reference for the timezones, and all
190     * the otehr values will be adjusted for that timezone
191     *
192     * @param DateTimeInterface[] $dt
193     * @param bool isFloating If set to true, timezones will be ignored
194     */
195    public function setDateTimes(array $dt, $isFloating = false)
196    {
197        $values = [];
198
199        if ($this->hasTime()) {
200            $tz = null;
201            $isUtc = false;
202
203            foreach ($dt as $d) {
204                if ($isFloating) {
205                    $values[] = $d->format('Ymd\\THis');
206                    continue;
207                }
208                if (is_null($tz)) {
209                    $tz = $d->getTimeZone();
210                    $isUtc = in_array($tz->getName(), ['UTC', 'GMT', 'Z', '+00:00']);
211                    if (!$isUtc) {
212                        $this->offsetSet('TZID', $tz->getName());
213                    }
214                } else {
215                    $d = $d->setTimeZone($tz);
216                }
217
218                if ($isUtc) {
219                    $values[] = $d->format('Ymd\\THis\\Z');
220                } else {
221                    $values[] = $d->format('Ymd\\THis');
222                }
223            }
224            if ($isUtc || $isFloating) {
225                $this->offsetUnset('TZID');
226            }
227        } else {
228            foreach ($dt as $d) {
229                $values[] = $d->format('Ymd');
230            }
231            $this->offsetUnset('TZID');
232        }
233
234        $this->value = $values;
235    }
236
237    /**
238     * Returns the type of value.
239     *
240     * This corresponds to the VALUE= parameter. Every property also has a
241     * 'default' valueType.
242     *
243     * @return string
244     */
245    public function getValueType()
246    {
247        return $this->hasTime() ? 'DATE-TIME' : 'DATE';
248    }
249
250    /**
251     * Returns the value, in the format it should be encoded for JSON.
252     *
253     * This method must always return an array.
254     *
255     * @return array
256     */
257    public function getJsonValue()
258    {
259        $dts = $this->getDateTimes();
260        $hasTime = $this->hasTime();
261        $isFloating = $this->isFloating();
262
263        $tz = $dts[0]->getTimeZone();
264        $isUtc = $isFloating ? false : in_array($tz->getName(), ['UTC', 'GMT', 'Z']);
265
266        return array_map(
267            function (DateTimeInterface $dt) use ($hasTime, $isUtc) {
268                if ($hasTime) {
269                    return $dt->format('Y-m-d\\TH:i:s').($isUtc ? 'Z' : '');
270                } else {
271                    return $dt->format('Y-m-d');
272                }
273            },
274            $dts
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    public function setJsonValue(array $value)
286    {
287        // dates and times in jCal have one difference to dates and times in
288        // iCalendar. In jCal date-parts are separated by dashes, and
289        // time-parts are separated by colons. It makes sense to just remove
290        // those.
291        $this->setValue(
292            array_map(
293                function ($item) {
294                    return strtr($item, [':' => '', '-' => '']);
295                },
296                $value
297            )
298        );
299    }
300
301    /**
302     * We need to intercept offsetSet, because it may be used to alter the
303     * VALUE from DATE-TIME to DATE or vice-versa.
304     *
305     * @param string $name
306     * @param mixed  $value
307     */
308    public function offsetSet($name, $value)
309    {
310        parent::offsetSet($name, $value);
311        if ('VALUE' !== strtoupper($name)) {
312            return;
313        }
314
315        // This will ensure that dates are correctly encoded.
316        $this->setDateTimes($this->getDateTimes());
317    }
318
319    /**
320     * Validates the node for correctness.
321     *
322     * The following options are supported:
323     *   Node::REPAIR - May attempt to automatically repair the problem.
324     *
325     * This method returns an array with detected problems.
326     * Every element has the following properties:
327     *
328     *  * level - problem level.
329     *  * message - A human-readable string describing the issue.
330     *  * node - A reference to the problematic node.
331     *
332     * The level means:
333     *   1 - The issue was repaired (only happens if REPAIR was turned on)
334     *   2 - An inconsequential issue
335     *   3 - A severe issue.
336     *
337     * @param int $options
338     *
339     * @return array
340     */
341    public function validate($options = 0)
342    {
343        $messages = parent::validate($options);
344        $valueType = $this->getValueType();
345        $values = $this->getParts();
346        try {
347            foreach ($values as $value) {
348                switch ($valueType) {
349                    case 'DATE':
350                        DateTimeParser::parseDate($value);
351                        break;
352                    case 'DATE-TIME':
353                        DateTimeParser::parseDateTime($value);
354                        break;
355                }
356            }
357        } catch (InvalidDataException $e) {
358            $messages[] = [
359                'level' => 3,
360                'message' => 'The supplied value ('.$value.') is not a correct '.$valueType,
361                'node' => $this,
362            ];
363        }
364
365        return $messages;
366    }
367}
368