1<?php
2
3namespace Sabre\VObject;
4
5use DateTime;
6use DateTimeZone;
7use DateInterval;
8use InvalidArgumentException;
9use LogicException;
10
11/**
12 * DateTimeParser
13 *
14 * This class is responsible for parsing the several different date and time
15 * formats iCalendar and vCards have.
16 *
17 * @copyright Copyright (C) fruux GmbH (https://fruux.com/)
18 * @author Evert Pot (http://evertpot.com/)
19 * @license http://sabre.io/license/ Modified BSD License
20 */
21class DateTimeParser {
22
23    /**
24     * Parses an iCalendar (rfc5545) formatted datetime and returns a DateTime object
25     *
26     * Specifying a reference timezone is optional. It will only be used
27     * if the non-UTC format is used. The argument is used as a reference, the
28     * returned DateTime object will still be in the UTC timezone.
29     *
30     * @param string $dt
31     * @param DateTimeZone $tz
32     * @return DateTime
33     */
34    static public 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 LogicException('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        $date = new DateTime($matches[1] . '-' . $matches[2] . '-' . $matches[3] . ' ' . $matches[4] . ':' . $matches[5] .':' . $matches[6], $tz);
47
48        // Still resetting the timezone, to normalize everything to UTC
49        // $date->setTimeZone(new \DateTimeZone('UTC'));
50        return $date;
51
52    }
53
54    /**
55     * Parses an iCalendar (rfc5545) formatted date and returns a DateTime object.
56     *
57     * @param string $date
58     * @param DateTimeZone $tz
59     * @return DateTime
60     */
61    static public function parseDate($date, DateTimeZone $tz = null) {
62
63        // Format is YYYYMMDD
64        $result = preg_match('/^([0-9]{4})([0-1][0-9])([0-3][0-9])$/',$date,$matches);
65
66        if (!$result) {
67            throw new LogicException('The supplied iCalendar date value is incorrect: ' . $date);
68        }
69
70        if (is_null($tz)) {
71            $tz = new DateTimeZone('UTC');
72        }
73
74        $date = new DateTime($matches[1] . '-' . $matches[2] . '-' . $matches[3], $tz);
75        return $date;
76
77    }
78
79    /**
80     * Parses an iCalendar (RFC5545) formatted duration value.
81     *
82     * This method will either return a DateTimeInterval object, or a string
83     * suitable for strtotime or DateTime::modify.
84     *
85     * @param string $duration
86     * @param bool $asString
87     * @return DateInterval|string
88     */
89    static public function parseDuration($duration, $asString = false) {
90
91        $result = preg_match('/^(?P<plusminus>\+|-)?P((?P<week>\d+)W)?((?P<day>\d+)D)?(T((?P<hour>\d+)H)?((?P<minute>\d+)M)?((?P<second>\d+)S)?)?$/', $duration, $matches);
92        if (!$result) {
93            throw new LogicException('The supplied iCalendar duration value is incorrect: ' . $duration);
94        }
95
96        if (!$asString) {
97            $invert = false;
98            if ($matches['plusminus']==='-') {
99                $invert = true;
100            }
101
102
103            $parts = array(
104                'week',
105                'day',
106                'hour',
107                'minute',
108                'second',
109            );
110            foreach($parts as $part) {
111                $matches[$part] = isset($matches[$part])&&$matches[$part]?(int)$matches[$part]:0;
112            }
113
114
115            // We need to re-construct the $duration string, because weeks and
116            // days are not supported by DateInterval in the same string.
117            $duration = 'P';
118            $days = $matches['day'];
119            if ($matches['week']) {
120                $days+=$matches['week']*7;
121            }
122            if ($days)
123                $duration.=$days . 'D';
124
125            if ($matches['minute'] || $matches['second'] || $matches['hour']) {
126                $duration.='T';
127
128                if ($matches['hour'])
129                    $duration.=$matches['hour'].'H';
130
131                if ($matches['minute'])
132                    $duration.=$matches['minute'].'M';
133
134                if ($matches['second'])
135                    $duration.=$matches['second'].'S';
136
137            }
138
139            if ($duration==='P') {
140                $duration = 'PT0S';
141            }
142            $iv = new DateInterval($duration);
143            if ($invert) $iv->invert = true;
144
145            return $iv;
146
147        }
148
149
150
151        $parts = array(
152            'week',
153            'day',
154            'hour',
155            'minute',
156            'second',
157        );
158
159        $newDur = '';
160        foreach($parts as $part) {
161            if (isset($matches[$part]) && $matches[$part]) {
162                $newDur.=' '.$matches[$part] . ' ' . $part . 's';
163            }
164        }
165
166        $newDur = ($matches['plusminus']==='-'?'-':'+') . trim($newDur);
167        if ($newDur === '+') {
168            $newDur = '+0 seconds';
169        };
170        return $newDur;
171
172    }
173
174    /**
175     * Parses either a Date or DateTime, or Duration value.
176     *
177     * @param string $date
178     * @param DateTimeZone|string $referenceTz
179     * @return DateTime|DateInterval
180     */
181    static public function parse($date, $referenceTz = null) {
182
183        if ($date[0]==='P' || ($date[0]==='-' && $date[1]==='P')) {
184            return self::parseDuration($date);
185        } elseif (strlen($date)===8) {
186            return self::parseDate($date, $referenceTz);
187        } else {
188            return self::parseDateTime($date, $referenceTz);
189        }
190
191    }
192
193    /**
194     * This method parses a vCard date and or time value.
195     *
196     * This can be used for the DATE, DATE-TIME, TIMESTAMP and
197     * DATE-AND-OR-TIME value.
198     *
199     * This method returns an array, not a DateTime value.
200     *
201     * The elements in the array are in the following order:
202     * year, month, date, hour, minute, second, timezone
203     *
204     * Almost any part of the string may be omitted. It's for example legal to
205     * just specify seconds, leave out the year, etc.
206     *
207     * Timezone is either returned as 'Z' or as '+08:00'
208     *
209     * For any non-specified values null is returned.
210     *
211     * List of date formats that are supported:
212     * YYYY
213     * YYYY-MM
214     * YYYYMMDD
215     * --MMDD
216     * ---DD
217     *
218     * YYYY-MM-DD
219     * --MM-DD
220     * ---DD
221     *
222     * List of supported time formats:
223     *
224     * HH
225     * HHMM
226     * HHMMSS
227     * -MMSS
228     * --SS
229     *
230     * HH
231     * HH:MM
232     * HH:MM:SS
233     * -MM:SS
234     * --SS
235     *
236     * A full basic-format date-time string looks like :
237     * 20130603T133901
238     *
239     * A full extended-format date-time string looks like :
240     * 2013-06-03T13:39:01
241     *
242     * Times may be postfixed by a timezone offset. This can be either 'Z' for
243     * UTC, or a string like -0500 or +1100.
244     *
245     * @param string $date
246     * @return array
247     */
248    static public function parseVCardDateTime($date) {
249
250        $regex = '/^
251            (?:  # date part
252                (?:
253                    (?: (?P<year> [0-9]{4}) (?: -)?| --)
254                    (?P<month> [0-9]{2})?
255                |---)
256                (?P<date> [0-9]{2})?
257            )?
258            (?:T  # time part
259                (?P<hour> [0-9]{2} | -)
260                (?P<minute> [0-9]{2} | -)?
261                (?P<second> [0-9]{2})?
262
263                (?: \.[0-9]{3})? # milliseconds
264                (?P<timezone> # timezone offset
265
266                    Z | (?: \+|-)(?: [0-9]{4})
267
268                )?
269
270            )?
271            $/x';
272
273        if (!preg_match($regex, $date, $matches)) {
274
275            // Attempting to parse the extended format.
276            $regex = '/^
277                (?: # date part
278                    (?: (?P<year> [0-9]{4}) - | -- )
279                    (?P<month> [0-9]{2}) -
280                    (?P<date> [0-9]{2})
281                )?
282                (?:T # time part
283
284                    (?: (?P<hour> [0-9]{2}) : | -)
285                    (?: (?P<minute> [0-9]{2}) : | -)?
286                    (?P<second> [0-9]{2})?
287
288                    (?: \.[0-9]{3})? # milliseconds
289                    (?P<timezone> # timezone offset
290
291                        Z | (?: \+|-)(?: [0-9]{2}:[0-9]{2})
292
293                    )?
294
295                )?
296                $/x';
297
298            if (!preg_match($regex, $date, $matches)) {
299                throw new InvalidArgumentException('Invalid vCard date-time string: ' . $date);
300            }
301
302        }
303        $parts = array(
304            'year',
305            'month',
306            'date',
307            'hour',
308            'minute',
309            'second',
310            'timezone'
311        );
312
313        $result = array();
314        foreach($parts as $part) {
315
316            if (empty($matches[$part])) {
317                $result[$part] = null;
318            } elseif ($matches[$part] === '-' || $matches[$part] === '--') {
319                $result[$part] = null;
320            } else {
321                $result[$part] = $matches[$part];
322            }
323
324        }
325
326        return $result;
327
328    }
329
330    /**
331     * This method parses a vCard TIME value.
332     *
333     * This method returns an array, not a DateTime value.
334     *
335     * The elements in the array are in the following order:
336     * hour, minute, second, timezone
337     *
338     * Almost any part of the string may be omitted. It's for example legal to
339     * just specify seconds, leave out the hour etc.
340     *
341     * Timezone is either returned as 'Z' or as '+08:00'
342     *
343     * For any non-specified values null is returned.
344     *
345     * List of supported time formats:
346     *
347     * HH
348     * HHMM
349     * HHMMSS
350     * -MMSS
351     * --SS
352     *
353     * HH
354     * HH:MM
355     * HH:MM:SS
356     * -MM:SS
357     * --SS
358     *
359     * A full basic-format time string looks like :
360     * 133901
361     *
362     * A full extended-format time string looks like :
363     * 13:39:01
364     *
365     * Times may be postfixed by a timezone offset. This can be either 'Z' for
366     * UTC, or a string like -0500 or +11:00.
367     *
368     * @param string $date
369     * @return array
370     */
371    static public function parseVCardTime($date) {
372
373        $regex = '/^
374            (?P<hour> [0-9]{2} | -)
375            (?P<minute> [0-9]{2} | -)?
376            (?P<second> [0-9]{2})?
377
378            (?: \.[0-9]{3})? # milliseconds
379            (?P<timezone> # timezone offset
380
381                Z | (?: \+|-)(?: [0-9]{4})
382
383            )?
384            $/x';
385
386
387        if (!preg_match($regex, $date, $matches)) {
388
389            // Attempting to parse the extended format.
390            $regex = '/^
391                (?: (?P<hour> [0-9]{2}) : | -)
392                (?: (?P<minute> [0-9]{2}) : | -)?
393                (?P<second> [0-9]{2})?
394
395                (?: \.[0-9]{3})? # milliseconds
396                (?P<timezone> # timezone offset
397
398                    Z | (?: \+|-)(?: [0-9]{2}:[0-9]{2})
399
400                )?
401                $/x';
402
403            if (!preg_match($regex, $date, $matches)) {
404                throw new InvalidArgumentException('Invalid vCard time string: ' . $date);
405            }
406
407        }
408        $parts = array(
409            'hour',
410            'minute',
411            'second',
412            'timezone'
413        );
414
415        $result = array();
416        foreach($parts as $part) {
417
418            if (empty($matches[$part])) {
419                $result[$part] = null;
420            } elseif ($matches[$part] === '-') {
421                $result[$part] = null;
422            } else {
423                $result[$part] = $matches[$part];
424            }
425
426        }
427
428        return $result;
429
430    }
431}
432