1<?php
2
3namespace Sabre\VObject\Property\VCard;
4
5use DateTime;
6use DateTimeImmutable;
7use DateTimeInterface;
8use Sabre\VObject\DateTimeParser;
9use Sabre\VObject\InvalidDataException;
10use Sabre\VObject\Property;
11use Sabre\Xml;
12
13/**
14 * DateAndOrTime property.
15 *
16 * This object encodes DATE-AND-OR-TIME values.
17 *
18 * @copyright Copyright (C) fruux GmbH (https://fruux.com/)
19 * @author Evert Pot (http://evertpot.com/)
20 * @license http://sabre.io/license/ Modified BSD License
21 */
22class DateAndOrTime extends Property
23{
24    /**
25     * Field separator.
26     *
27     * @var string|null
28     */
29    public $delimiter = null;
30
31    /**
32     * Returns the type of value.
33     *
34     * This corresponds to the VALUE= parameter. Every property also has a
35     * 'default' valueType.
36     *
37     * @return string
38     */
39    public function getValueType()
40    {
41        return 'DATE-AND-OR-TIME';
42    }
43
44    /**
45     * Sets a multi-valued property.
46     *
47     * You may also specify DateTimeInterface objects here.
48     *
49     * @param array $parts
50     */
51    public function setParts(array $parts)
52    {
53        if (count($parts) > 1) {
54            throw new \InvalidArgumentException('Only one value allowed');
55        }
56        if (isset($parts[0]) && $parts[0] instanceof DateTimeInterface) {
57            $this->setDateTime($parts[0]);
58        } else {
59            parent::setParts($parts);
60        }
61    }
62
63    /**
64     * Updates the current value.
65     *
66     * This may be either a single, or multiple strings in an array.
67     *
68     * Instead of strings, you may also use DateTimeInterface here.
69     *
70     * @param string|array|DateTimeInterface $value
71     */
72    public function setValue($value)
73    {
74        if ($value instanceof DateTimeInterface) {
75            $this->setDateTime($value);
76        } else {
77            parent::setValue($value);
78        }
79    }
80
81    /**
82     * Sets the property as a DateTime object.
83     *
84     * @param DateTimeInterface $dt
85     */
86    public function setDateTime(DateTimeInterface $dt)
87    {
88        $tz = $dt->getTimeZone();
89        $isUtc = in_array($tz->getName(), ['UTC', 'GMT', 'Z']);
90
91        if ($isUtc) {
92            $value = $dt->format('Ymd\\THis\\Z');
93        } else {
94            // Calculating the offset.
95            $value = $dt->format('Ymd\\THisO');
96        }
97
98        $this->value = $value;
99    }
100
101    /**
102     * Returns a date-time value.
103     *
104     * Note that if this property contained more than 1 date-time, only the
105     * first will be returned. To get an array with multiple values, call
106     * getDateTimes.
107     *
108     * If no time was specified, we will always use midnight (in the default
109     * timezone) as the time.
110     *
111     * If parts of the date were omitted, such as the year, we will grab the
112     * current values for those. So at the time of writing, if the year was
113     * omitted, we would have filled in 2014.
114     *
115     * @return DateTimeImmutable
116     */
117    public function getDateTime()
118    {
119        $now = new DateTime();
120
121        $tzFormat = 0 === $now->getTimezone()->getOffset($now) ? '\\Z' : 'O';
122        $nowParts = DateTimeParser::parseVCardDateTime($now->format('Ymd\\This'.$tzFormat));
123
124        $dateParts = DateTimeParser::parseVCardDateTime($this->getValue());
125
126        // This sets all the missing parts to the current date/time.
127        // So if the year was missing for a birthday, we're making it 'this
128        // year'.
129        foreach ($dateParts as $k => $v) {
130            if (is_null($v)) {
131                $dateParts[$k] = $nowParts[$k];
132            }
133        }
134
135        return new DateTimeImmutable("$dateParts[year]-$dateParts[month]-$dateParts[date] $dateParts[hour]:$dateParts[minute]:$dateParts[second] $dateParts[timezone]");
136    }
137
138    /**
139     * Returns the value, in the format it should be encoded for json.
140     *
141     * This method must always return an array.
142     *
143     * @return array
144     */
145    public function getJsonValue()
146    {
147        $parts = DateTimeParser::parseVCardDateTime($this->getValue());
148
149        $dateStr = '';
150
151        // Year
152        if (!is_null($parts['year'])) {
153            $dateStr .= $parts['year'];
154
155            if (!is_null($parts['month'])) {
156                // If a year and a month is set, we need to insert a separator
157                // dash.
158                $dateStr .= '-';
159            }
160        } else {
161            if (!is_null($parts['month']) || !is_null($parts['date'])) {
162                // Inserting two dashes
163                $dateStr .= '--';
164            }
165        }
166
167        // Month
168        if (!is_null($parts['month'])) {
169            $dateStr .= $parts['month'];
170
171            if (isset($parts['date'])) {
172                // If month and date are set, we need the separator dash.
173                $dateStr .= '-';
174            }
175        } elseif (isset($parts['date'])) {
176            // If the month is empty, and a date is set, we need a 'empty
177            // dash'
178            $dateStr .= '-';
179        }
180
181        // Date
182        if (!is_null($parts['date'])) {
183            $dateStr .= $parts['date'];
184        }
185
186        // Early exit if we don't have a time string.
187        if (is_null($parts['hour']) && is_null($parts['minute']) && is_null($parts['second'])) {
188            return [$dateStr];
189        }
190
191        $dateStr .= 'T';
192
193        // Hour
194        if (!is_null($parts['hour'])) {
195            $dateStr .= $parts['hour'];
196
197            if (!is_null($parts['minute'])) {
198                $dateStr .= ':';
199            }
200        } else {
201            // We know either minute or second _must_ be set, so we insert a
202            // dash for an empty value.
203            $dateStr .= '-';
204        }
205
206        // Minute
207        if (!is_null($parts['minute'])) {
208            $dateStr .= $parts['minute'];
209
210            if (!is_null($parts['second'])) {
211                $dateStr .= ':';
212            }
213        } elseif (isset($parts['second'])) {
214            // Dash for empty minute
215            $dateStr .= '-';
216        }
217
218        // Second
219        if (!is_null($parts['second'])) {
220            $dateStr .= $parts['second'];
221        }
222
223        // Timezone
224        if (!is_null($parts['timezone'])) {
225            $dateStr .= $parts['timezone'];
226        }
227
228        return [$dateStr];
229    }
230
231    /**
232     * This method serializes only the value of a property. This is used to
233     * create xCard or xCal documents.
234     *
235     * @param Xml\Writer $writer XML writer
236     */
237    protected function xmlSerializeValue(Xml\Writer $writer)
238    {
239        $valueType = strtolower($this->getValueType());
240        $parts = DateTimeParser::parseVCardDateAndOrTime($this->getValue());
241        $value = '';
242
243        // $d = defined
244        $d = function ($part) use ($parts) {
245            return !is_null($parts[$part]);
246        };
247
248        // $r = read
249        $r = function ($part) use ($parts) {
250            return $parts[$part];
251        };
252
253        // From the Relax NG Schema.
254        //
255        // # 4.3.1
256        // value-date = element date {
257        //     xsd:string { pattern = "\d{8}|\d{4}-\d\d|--\d\d(\d\d)?|---\d\d" }
258        //   }
259        if (($d('year') || $d('month') || $d('date'))
260            && (!$d('hour') && !$d('minute') && !$d('second') && !$d('timezone'))) {
261            if ($d('year') && $d('month') && $d('date')) {
262                $value .= $r('year').$r('month').$r('date');
263            } elseif ($d('year') && $d('month') && !$d('date')) {
264                $value .= $r('year').'-'.$r('month');
265            } elseif (!$d('year') && $d('month')) {
266                $value .= '--'.$r('month').$r('date');
267            } elseif (!$d('year') && !$d('month') && $d('date')) {
268                $value .= '---'.$r('date');
269            }
270
271            // # 4.3.2
272        // value-time = element time {
273        //     xsd:string { pattern = "(\d\d(\d\d(\d\d)?)?|-\d\d(\d\d?)|--\d\d)"
274        //                          ~ "(Z|[+\-]\d\d(\d\d)?)?" }
275        //   }
276        } elseif ((!$d('year') && !$d('month') && !$d('date'))
277                  && ($d('hour') || $d('minute') || $d('second'))) {
278            if ($d('hour')) {
279                $value .= $r('hour').$r('minute').$r('second');
280            } elseif ($d('minute')) {
281                $value .= '-'.$r('minute').$r('second');
282            } elseif ($d('second')) {
283                $value .= '--'.$r('second');
284            }
285
286            $value .= $r('timezone');
287
288        // # 4.3.3
289        // value-date-time = element date-time {
290        //     xsd:string { pattern = "(\d{8}|--\d{4}|---\d\d)T\d\d(\d\d(\d\d)?)?"
291        //                          ~ "(Z|[+\-]\d\d(\d\d)?)?" }
292        //   }
293        } elseif ($d('date') && $d('hour')) {
294            if ($d('year') && $d('month') && $d('date')) {
295                $value .= $r('year').$r('month').$r('date');
296            } elseif (!$d('year') && $d('month') && $d('date')) {
297                $value .= '--'.$r('month').$r('date');
298            } elseif (!$d('year') && !$d('month') && $d('date')) {
299                $value .= '---'.$r('date');
300            }
301
302            $value .= 'T'.$r('hour').$r('minute').$r('second').
303                      $r('timezone');
304        }
305
306        $writer->writeElement($valueType, $value);
307    }
308
309    /**
310     * Sets a raw value coming from a mimedir (iCalendar/vCard) file.
311     *
312     * This has been 'unfolded', so only 1 line will be passed. Unescaping is
313     * not yet done, but parameters are not included.
314     *
315     * @param string $val
316     */
317    public function setRawMimeDirValue($val)
318    {
319        $this->setValue($val);
320    }
321
322    /**
323     * Returns a raw mime-dir representation of the value.
324     *
325     * @return string
326     */
327    public function getRawMimeDirValue()
328    {
329        return implode($this->delimiter, $this->getParts());
330    }
331
332    /**
333     * Validates the node for correctness.
334     *
335     * The following options are supported:
336     *   Node::REPAIR - May attempt to automatically repair the problem.
337     *
338     * This method returns an array with detected problems.
339     * Every element has the following properties:
340     *
341     *  * level - problem level.
342     *  * message - A human-readable string describing the issue.
343     *  * node - A reference to the problematic node.
344     *
345     * The level means:
346     *   1 - The issue was repaired (only happens if REPAIR was turned on)
347     *   2 - An inconsequential issue
348     *   3 - A severe issue.
349     *
350     * @param int $options
351     *
352     * @return array
353     */
354    public function validate($options = 0)
355    {
356        $messages = parent::validate($options);
357        $value = $this->getValue();
358
359        try {
360            DateTimeParser::parseVCardDateTime($value);
361        } catch (InvalidDataException $e) {
362            $messages[] = [
363                'level' => 3,
364                'message' => 'The supplied value ('.$value.') is not a correct DATE-AND-OR-TIME property',
365                'node' => $this,
366            ];
367        }
368
369        return $messages;
370    }
371}
372