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