1<?php
2
3namespace Sabre\VObject;
4
5use DateInterval;
6use DateTimeImmutable;
7use DateTimeZone;
8
9/**
10 * DateTimeParser.
11 *
12 * This class is responsible for parsing the several different date and time
13 * formats iCalendar and vCards have.
14 *
15 * @copyright Copyright (C) fruux GmbH (https://fruux.com/)
16 * @author Evert Pot (http://evertpot.com/)
17 * @license http://sabre.io/license/ Modified BSD License
18 */
19class DateTimeParser
20{
21    /**
22     * Parses an iCalendar (rfc5545) formatted datetime and returns a
23     * DateTimeImmutable object.
24     *
25     * Specifying a reference timezone is optional. It will only be used
26     * if the non-UTC format is used. The argument is used as a reference, the
27     * returned DateTimeImmutable object will still be in the UTC timezone.
28     *
29     * @param string       $dt
30     * @param DateTimeZone $tz
31     *
32     * @return DateTimeImmutable
33     */
34    public static function parseDateTime($dt, DateTimeZone $tz = null)
35    {
36        // Format is YYYYMMDD + "T" + hhmmss
37        $result = preg_match('/^([0-9]{4})([0-1][0-9])([0-3][0-9])T([0-2][0-9])([0-5][0-9])([0-5][0-9])([Z]?)$/', $dt, $matches);
38
39        if (!$result) {
40            throw new InvalidDataException('The supplied iCalendar datetime value is incorrect: '.$dt);
41        }
42
43        if ('Z' === $matches[7] || is_null($tz)) {
44            $tz = new DateTimeZone('UTC');
45        }
46
47        try {
48            $date = new DateTimeImmutable($matches[1].'-'.$matches[2].'-'.$matches[3].' '.$matches[4].':'.$matches[5].':'.$matches[6], $tz);
49        } catch (\Exception $e) {
50            throw new InvalidDataException('The supplied iCalendar datetime value is incorrect: '.$dt);
51        }
52
53        return $date;
54    }
55
56    /**
57     * Parses an iCalendar (rfc5545) formatted date and returns a DateTimeImmutable object.
58     *
59     * @param string       $date
60     * @param DateTimeZone $tz
61     *
62     * @return DateTimeImmutable
63     */
64    public static function parseDate($date, DateTimeZone $tz = null)
65    {
66        // Format is YYYYMMDD
67        $result = preg_match('/^([0-9]{4})([0-1][0-9])([0-3][0-9])$/', $date, $matches);
68
69        if (!$result) {
70            throw new InvalidDataException('The supplied iCalendar date value is incorrect: '.$date);
71        }
72
73        if (is_null($tz)) {
74            $tz = new DateTimeZone('UTC');
75        }
76
77        try {
78            $date = new DateTimeImmutable($matches[1].'-'.$matches[2].'-'.$matches[3], $tz);
79        } catch (\Exception $e) {
80            throw new InvalidDataException('The supplied iCalendar date value is incorrect: '.$date);
81        }
82
83        return $date;
84    }
85
86    /**
87     * Parses an iCalendar (RFC5545) formatted duration value.
88     *
89     * This method will either return a DateTimeInterval object, or a string
90     * suitable for strtotime or DateTime::modify.
91     *
92     * @param string $duration
93     * @param bool   $asString
94     *
95     * @return DateInterval|string
96     */
97    public static function parseDuration($duration, $asString = false)
98    {
99        $result = preg_match('/^(?<plusminus>\+|-)?P((?<week>\d+)W)?((?<day>\d+)D)?(T((?<hour>\d+)H)?((?<minute>\d+)M)?((?<second>\d+)S)?)?$/', $duration, $matches);
100        if (!$result) {
101            throw new InvalidDataException('The supplied iCalendar duration value is incorrect: '.$duration);
102        }
103
104        if (!$asString) {
105            $invert = false;
106
107            if ('-' === $matches['plusminus']) {
108                $invert = true;
109            }
110
111            $parts = [
112                'week',
113                'day',
114                'hour',
115                'minute',
116                'second',
117            ];
118
119            foreach ($parts as $part) {
120                $matches[$part] = isset($matches[$part]) && $matches[$part] ? (int) $matches[$part] : 0;
121            }
122
123            // We need to re-construct the $duration string, because weeks and
124            // days are not supported by DateInterval in the same string.
125            $duration = 'P';
126            $days = $matches['day'];
127
128            if ($matches['week']) {
129                $days += $matches['week'] * 7;
130            }
131
132            if ($days) {
133                $duration .= $days.'D';
134            }
135
136            if ($matches['minute'] || $matches['second'] || $matches['hour']) {
137                $duration .= 'T';
138
139                if ($matches['hour']) {
140                    $duration .= $matches['hour'].'H';
141                }
142
143                if ($matches['minute']) {
144                    $duration .= $matches['minute'].'M';
145                }
146
147                if ($matches['second']) {
148                    $duration .= $matches['second'].'S';
149                }
150            }
151
152            if ('P' === $duration) {
153                $duration = 'PT0S';
154            }
155
156            $iv = new DateInterval($duration);
157
158            if ($invert) {
159                $iv->invert = true;
160            }
161
162            return $iv;
163        }
164
165        $parts = [
166            'week',
167            'day',
168            'hour',
169            'minute',
170            'second',
171        ];
172
173        $newDur = '';
174
175        foreach ($parts as $part) {
176            if (isset($matches[$part]) && $matches[$part]) {
177                $newDur .= ' '.$matches[$part].' '.$part.'s';
178            }
179        }
180
181        $newDur = ('-' === $matches['plusminus'] ? '-' : '+').trim($newDur);
182
183        if ('+' === $newDur) {
184            $newDur = '+0 seconds';
185        }
186
187        return $newDur;
188    }
189
190    /**
191     * Parses either a Date or DateTime, or Duration value.
192     *
193     * @param string              $date
194     * @param DateTimeZone|string $referenceTz
195     *
196     * @return DateTimeImmutable|DateInterval
197     */
198    public static function parse($date, $referenceTz = null)
199    {
200        if ('P' === $date[0] || ('-' === $date[0] && 'P' === $date[1])) {
201            return self::parseDuration($date);
202        } elseif (8 === strlen($date)) {
203            return self::parseDate($date, $referenceTz);
204        } else {
205            return self::parseDateTime($date, $referenceTz);
206        }
207    }
208
209    /**
210     * This method parses a vCard date and or time value.
211     *
212     * This can be used for the DATE, DATE-TIME, TIMESTAMP and
213     * DATE-AND-OR-TIME value.
214     *
215     * This method returns an array, not a DateTime value.
216     *
217     * The elements in the array are in the following order:
218     * year, month, date, hour, minute, second, timezone
219     *
220     * Almost any part of the string may be omitted. It's for example legal to
221     * just specify seconds, leave out the year, etc.
222     *
223     * Timezone is either returned as 'Z' or as '+0800'
224     *
225     * For any non-specified values null is returned.
226     *
227     * List of date formats that are supported:
228     * YYYY
229     * YYYY-MM
230     * YYYYMMDD
231     * --MMDD
232     * ---DD
233     *
234     * YYYY-MM-DD
235     * --MM-DD
236     * ---DD
237     *
238     * List of supported time formats:
239     *
240     * HH
241     * HHMM
242     * HHMMSS
243     * -MMSS
244     * --SS
245     *
246     * HH
247     * HH:MM
248     * HH:MM:SS
249     * -MM:SS
250     * --SS
251     *
252     * A full basic-format date-time string looks like :
253     * 20130603T133901
254     *
255     * A full extended-format date-time string looks like :
256     * 2013-06-03T13:39:01
257     *
258     * Times may be postfixed by a timezone offset. This can be either 'Z' for
259     * UTC, or a string like -0500 or +1100.
260     *
261     * @param string $date
262     *
263     * @return array
264     */
265    public static function parseVCardDateTime($date)
266    {
267        $regex = '/^
268            (?:  # date part
269                (?:
270                    (?: (?<year> [0-9]{4}) (?: -)?| --)
271                    (?<month> [0-9]{2})?
272                |---)
273                (?<date> [0-9]{2})?
274            )?
275            (?:T  # time part
276                (?<hour> [0-9]{2} | -)
277                (?<minute> [0-9]{2} | -)?
278                (?<second> [0-9]{2})?
279
280                (?: \.[0-9]{3})? # milliseconds
281                (?P<timezone> # timezone offset
282
283                    Z | (?: \+|-)(?: [0-9]{4})
284
285                )?
286
287            )?
288            $/x';
289
290        if (!preg_match($regex, $date, $matches)) {
291            // Attempting to parse the extended format.
292            $regex = '/^
293                (?: # date part
294                    (?: (?<year> [0-9]{4}) - | -- )
295                    (?<month> [0-9]{2}) -
296                    (?<date> [0-9]{2})
297                )?
298                (?:T # time part
299
300                    (?: (?<hour> [0-9]{2}) : | -)
301                    (?: (?<minute> [0-9]{2}) : | -)?
302                    (?<second> [0-9]{2})?
303
304                    (?: \.[0-9]{3})? # milliseconds
305                    (?P<timezone> # timezone offset
306
307                        Z | (?: \+|-)(?: [0-9]{2}:[0-9]{2})
308
309                    )?
310
311                )?
312                $/x';
313
314            if (!preg_match($regex, $date, $matches)) {
315                throw new InvalidDataException('Invalid vCard date-time string: '.$date);
316            }
317        }
318        $parts = [
319            'year',
320            'month',
321            'date',
322            'hour',
323            'minute',
324            'second',
325            'timezone',
326        ];
327
328        $result = [];
329        foreach ($parts as $part) {
330            if (empty($matches[$part])) {
331                $result[$part] = null;
332            } elseif ('-' === $matches[$part] || '--' === $matches[$part]) {
333                $result[$part] = null;
334            } else {
335                $result[$part] = $matches[$part];
336            }
337        }
338
339        return $result;
340    }
341
342    /**
343     * This method parses a vCard TIME value.
344     *
345     * This method returns an array, not a DateTime value.
346     *
347     * The elements in the array are in the following order:
348     * hour, minute, second, timezone
349     *
350     * Almost any part of the string may be omitted. It's for example legal to
351     * just specify seconds, leave out the hour etc.
352     *
353     * Timezone is either returned as 'Z' or as '+08:00'
354     *
355     * For any non-specified values null is returned.
356     *
357     * List of supported time formats:
358     *
359     * HH
360     * HHMM
361     * HHMMSS
362     * -MMSS
363     * --SS
364     *
365     * HH
366     * HH:MM
367     * HH:MM:SS
368     * -MM:SS
369     * --SS
370     *
371     * A full basic-format time string looks like :
372     * 133901
373     *
374     * A full extended-format time string looks like :
375     * 13:39:01
376     *
377     * Times may be postfixed by a timezone offset. This can be either 'Z' for
378     * UTC, or a string like -0500 or +11:00.
379     *
380     * @param string $date
381     *
382     * @return array
383     */
384    public static function parseVCardTime($date)
385    {
386        $regex = '/^
387            (?<hour> [0-9]{2} | -)
388            (?<minute> [0-9]{2} | -)?
389            (?<second> [0-9]{2})?
390
391            (?: \.[0-9]{3})? # milliseconds
392            (?P<timezone> # timezone offset
393
394                Z | (?: \+|-)(?: [0-9]{4})
395
396            )?
397            $/x';
398
399        if (!preg_match($regex, $date, $matches)) {
400            // Attempting to parse the extended format.
401            $regex = '/^
402                (?: (?<hour> [0-9]{2}) : | -)
403                (?: (?<minute> [0-9]{2}) : | -)?
404                (?<second> [0-9]{2})?
405
406                (?: \.[0-9]{3})? # milliseconds
407                (?P<timezone> # timezone offset
408
409                    Z | (?: \+|-)(?: [0-9]{2}:[0-9]{2})
410
411                )?
412                $/x';
413
414            if (!preg_match($regex, $date, $matches)) {
415                throw new InvalidDataException('Invalid vCard time string: '.$date);
416            }
417        }
418        $parts = [
419            'hour',
420            'minute',
421            'second',
422            'timezone',
423        ];
424
425        $result = [];
426        foreach ($parts as $part) {
427            if (empty($matches[$part])) {
428                $result[$part] = null;
429            } elseif ('-' === $matches[$part]) {
430                $result[$part] = null;
431            } else {
432                $result[$part] = $matches[$part];
433            }
434        }
435
436        return $result;
437    }
438
439    /**
440     * This method parses a vCard date and or time value.
441     *
442     * This can be used for the DATE, DATE-TIME and
443     * DATE-AND-OR-TIME value.
444     *
445     * This method returns an array, not a DateTime value.
446     * The elements in the array are in the following order:
447     *     year, month, date, hour, minute, second, timezone
448     * Almost any part of the string may be omitted. It's for example legal to
449     * just specify seconds, leave out the year, etc.
450     *
451     * Timezone is either returned as 'Z' or as '+0800'
452     *
453     * For any non-specified values null is returned.
454     *
455     * List of date formats that are supported:
456     *     20150128
457     *     2015-01
458     *     --01
459     *     --0128
460     *     ---28
461     *
462     * List of supported time formats:
463     *     13
464     *     1353
465     *     135301
466     *     -53
467     *     -5301
468     *     --01 (unreachable, see the tests)
469     *     --01Z
470     *     --01+1234
471     *
472     * List of supported date-time formats:
473     *     20150128T13
474     *     --0128T13
475     *     ---28T13
476     *     ---28T1353
477     *     ---28T135301
478     *     ---28T13Z
479     *     ---28T13+1234
480     *
481     * See the regular expressions for all the possible patterns.
482     *
483     * Times may be postfixed by a timezone offset. This can be either 'Z' for
484     * UTC, or a string like -0500 or +1100.
485     *
486     * @param string $date
487     *
488     * @return array
489     */
490    public static function parseVCardDateAndOrTime($date)
491    {
492        // \d{8}|\d{4}-\d\d|--\d\d(\d\d)?|---\d\d
493        $valueDate = '/^(?J)(?:'.
494                         '(?<year>\d{4})(?<month>\d\d)(?<date>\d\d)'.
495                         '|(?<year>\d{4})-(?<month>\d\d)'.
496                         '|--(?<month>\d\d)(?<date>\d\d)?'.
497                         '|---(?<date>\d\d)'.
498                         ')$/';
499
500        // (\d\d(\d\d(\d\d)?)?|-\d\d(\d\d)?|--\d\d)(Z|[+\-]\d\d(\d\d)?)?
501        $valueTime = '/^(?J)(?:'.
502                         '((?<hour>\d\d)((?<minute>\d\d)(?<second>\d\d)?)?'.
503                         '|-(?<minute>\d\d)(?<second>\d\d)?'.
504                         '|--(?<second>\d\d))'.
505                         '(?<timezone>(Z|[+\-]\d\d(\d\d)?))?'.
506                         ')$/';
507
508        // (\d{8}|--\d{4}|---\d\d)T\d\d(\d\d(\d\d)?)?(Z|[+\-]\d\d(\d\d?)?
509        $valueDateTime = '/^(?:'.
510                         '((?<year0>\d{4})(?<month0>\d\d)(?<date0>\d\d)'.
511                         '|--(?<month1>\d\d)(?<date1>\d\d)'.
512                         '|---(?<date2>\d\d))'.
513                         'T'.
514                         '(?<hour>\d\d)((?<minute>\d\d)(?<second>\d\d)?)?'.
515                         '(?<timezone>(Z|[+\-]\d\d(\d\d?)))?'.
516                         ')$/';
517
518        // date-and-or-time is date | date-time | time
519        // in this strict order.
520
521        if (0 === preg_match($valueDate, $date, $matches)
522            && 0 === preg_match($valueDateTime, $date, $matches)
523            && 0 === preg_match($valueTime, $date, $matches)) {
524            throw new InvalidDataException('Invalid vCard date-time string: '.$date);
525        }
526
527        $parts = [
528            'year' => null,
529            'month' => null,
530            'date' => null,
531            'hour' => null,
532            'minute' => null,
533            'second' => null,
534            'timezone' => null,
535        ];
536
537        // The $valueDateTime expression has a bug with (?J) so we simulate it.
538        $parts['date0'] = &$parts['date'];
539        $parts['date1'] = &$parts['date'];
540        $parts['date2'] = &$parts['date'];
541        $parts['month0'] = &$parts['month'];
542        $parts['month1'] = &$parts['month'];
543        $parts['year0'] = &$parts['year'];
544
545        foreach ($parts as $part => &$value) {
546            if (!empty($matches[$part])) {
547                $value = $matches[$part];
548            }
549        }
550
551        unset($parts['date0']);
552        unset($parts['date1']);
553        unset($parts['date2']);
554        unset($parts['month0']);
555        unset($parts['month1']);
556        unset($parts['year0']);
557
558        return $parts;
559    }
560}
561