<?php
namespace Sabre\VObject;
use DateTimeImmutable;
use DateTimeZone;
use DateInterval;
/**
* DateTimeParser.
*
* This class is responsible for parsing the several different date and time
* formats iCalendar and vCards have.
*
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
* @author Evert Pot (http://evertpot.com/)
* @license http://sabre.io/license/ Modified BSD License
*/
class DateTimeParser {
/**
* Parses an iCalendar (rfc5545) formatted datetime and returns a
* DateTimeImmutable object.
*
* Specifying a reference timezone is optional. It will only be used
* if the non-UTC format is used. The argument is used as a reference, the
* returned DateTimeImmutable object will still be in the UTC timezone.
*
* @param string $dt
* @param DateTimeZone $tz
*
* @return DateTimeImmutable
*/
static function parseDateTime($dt, DateTimeZone $tz = null) {
// Format is YYYYMMDD + "T" + hhmmss
$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);
if (!$result) {
throw new InvalidDataException('The supplied iCalendar datetime value is incorrect: ' . $dt);
}
if ($matches[7] === 'Z' || is_null($tz)) {
$tz = new DateTimeZone('UTC');
}
try {
$date = new DateTimeImmutable($matches[1] . '-' . $matches[2] . '-' . $matches[3] . ' ' . $matches[4] . ':' . $matches[5] . ':' . $matches[6], $tz);
} catch (\Exception $e) {
throw new InvalidDataException('The supplied iCalendar datetime value is incorrect: ' . $dt);
}
return $date;
}
/**
* Parses an iCalendar (rfc5545) formatted date and returns a DateTimeImmutable object.
*
* @param string $date
* @param DateTimeZone $tz
*
* @return DateTimeImmutable
*/
static function parseDate($date, DateTimeZone $tz = null) {
// Format is YYYYMMDD
$result = preg_match('/^([0-9]{4})([0-1][0-9])([0-3][0-9])$/', $date, $matches);
if (!$result) {
throw new InvalidDataException('The supplied iCalendar date value is incorrect: ' . $date);
}
if (is_null($tz)) {
$tz = new DateTimeZone('UTC');
}
try {
$date = new DateTimeImmutable($matches[1] . '-' . $matches[2] . '-' . $matches[3], $tz);
} catch (\Exception $e) {
throw new InvalidDataException('The supplied iCalendar date value is incorrect: ' . $date);
}
return $date;
}
/**
* Parses an iCalendar (RFC5545) formatted duration value.
*
* This method will either return a DateTimeInterval object, or a string
* suitable for strtotime or DateTime::modify.
*
* @param string $duration
* @param bool $asString
*
* @return DateInterval|string
*/
static function parseDuration($duration, $asString = false) {
$result = preg_match('/^(?<plusminus>\+|-)?P((?<week>\d+)W)?((?<day>\d+)D)?(T((?<hour>\d+)H)?((?<minute>\d+)M)?((?<second>\d+)S)?)?$/', $duration, $matches);
if (!$result) {
throw new InvalidDataException('The supplied iCalendar duration value is incorrect: ' . $duration);
}
if (!$asString) {
$invert = false;
if ($matches['plusminus'] === '-') {
$invert = true;
}
$parts = [
'week',
'day',
'hour',
'minute',
'second',
];
foreach ($parts as $part) {
$matches[$part] = isset($matches[$part]) && $matches[$part] ? (int)$matches[$part] : 0;
}
// We need to re-construct the $duration string, because weeks and
// days are not supported by DateInterval in the same string.
$duration = 'P';
$days = $matches['day'];
if ($matches['week']) {
$days += $matches['week'] * 7;
}
if ($days) {
$duration .= $days . 'D';
}
if ($matches['minute'] || $matches['second'] || $matches['hour']) {
$duration .= 'T';
if ($matches['hour']) {
$duration .= $matches['hour'] . 'H';
}
if ($matches['minute']) {
$duration .= $matches['minute'] . 'M';
}
if ($matches['second']) {
$duration .= $matches['second'] . 'S';
}
}
if ($duration === 'P') {
$duration = 'PT0S';
}
$iv = new DateInterval($duration);
if ($invert) {
$iv->invert = true;
}
return $iv;
}
$parts = [
'week',
'day',
'hour',
'minute',
'second',
];
$newDur = '';
foreach ($parts as $part) {
if (isset($matches[$part]) && $matches[$part]) {
$newDur .= ' ' . $matches[$part] . ' ' . $part . 's';
}
}
$newDur = ($matches['plusminus'] === '-' ? '-' : '+') . trim($newDur);
if ($newDur === '+') {
$newDur = '+0 seconds';
};
return $newDur;
}
/**
* Parses either a Date or DateTime, or Duration value.
*
* @param string $date
* @param DateTimeZone|string $referenceTz
*
* @return DateTimeImmutable|DateInterval
*/
static function parse($date, $referenceTz = null) {
if ($date[0] === 'P' || ($date[0] === '-' && $date[1] === 'P')) {
return self::parseDuration($date);
} elseif (strlen($date) === 8) {
return self::parseDate($date, $referenceTz);
} else {
return self::parseDateTime($date, $referenceTz);
}
}
/**
* This method parses a vCard date and or time value.
*
* This can be used for the DATE, DATE-TIME, TIMESTAMP and
* DATE-AND-OR-TIME value.
*
* This method returns an array, not a DateTime value.
*
* The elements in the array are in the following order:
* year, month, date, hour, minute, second, timezone
*
* Almost any part of the string may be omitted. It's for example legal to
* just specify seconds, leave out the year, etc.
*
* Timezone is either returned as 'Z' or as '+0800'
*
* For any non-specified values null is returned.
*
* List of date formats that are supported:
* YYYY
* YYYY-MM
* YYYYMMDD
* --MMDD
* ---DD
*
* YYYY-MM-DD
* --MM-DD
* ---DD
*
* List of supported time formats:
*
* HH
* HHMM
* HHMMSS
* -MMSS
* --SS
*
* HH
* HH:MM
* HH:MM:SS
* -MM:SS
* --SS
*
* A full basic-format date-time string looks like :
* 20130603T133901
*
* A full extended-format date-time string looks like :
* 2013-06-03T13:39:01
*
* Times may be postfixed by a timezone offset. This can be either 'Z' for
* UTC, or a string like -0500 or +1100.
*
* @param string $date
*
* @return array
*/
static function parseVCardDateTime($date) {
$regex = '/^
(?: # date part
(?:
(?: (?<year> [0-9]{4}) (?: -)?| --)
(?<month> [0-9]{2})?
|---)
(?<date> [0-9]{2})?
)?
(?:T # time part
(?<hour> [0-9]{2} | -)
(?<minute> [0-9]{2} | -)?
(?<second> [0-9]{2})?
(?: \.[0-9]{3})? # milliseconds
(?P<timezone> # timezone offset
Z | (?: \+|-)(?: [0-9]{4})
)?
)?
$/x';
if (!preg_match($regex, $date, $matches)) {
// Attempting to parse the extended format.
$regex = '/^
(?: # date part
(?: (?<year> [0-9]{4}) - | -- )
(?<month> [0-9]{2}) -
(?<date> [0-9]{2})
)?
(?:T # time part
(?: (?<hour> [0-9]{2}) : | -)
(?: (?<minute> [0-9]{2}) : | -)?
(?<second> [0-9]{2})?
(?: \.[0-9]{3})? # milliseconds
(?P<timezone> # timezone offset
Z | (?: \+|-)(?: [0-9]{2}:[0-9]{2})
)?
)?
$/x';
if (!preg_match($regex, $date, $matches)) {
throw new InvalidDataException('Invalid vCard date-time string: ' . $date);
}
}
$parts = [
'year',
'month',
'date',
'hour',
'minute',
'second',
'timezone'
];
$result = [];
foreach ($parts as $part) {
if (empty($matches[$part])) {
$result[$part] = null;
} elseif ($matches[$part] === '-' || $matches[$part] === '--') {
$result[$part] = null;
} else {
$result[$part] = $matches[$part];
}
}
return $result;
}
/**
* This method parses a vCard TIME value.
*
* This method returns an array, not a DateTime value.
*
* The elements in the array are in the following order:
* hour, minute, second, timezone
*
* Almost any part of the string may be omitted. It's for example legal to
* just specify seconds, leave out the hour etc.
*
* Timezone is either returned as 'Z' or as '+08:00'
*
* For any non-specified values null is returned.
*
* List of supported time formats:
*
* HH
* HHMM
* HHMMSS
* -MMSS
* --SS
*
* HH
* HH:MM
* HH:MM:SS
* -MM:SS
* --SS
*
* A full basic-format time string looks like :
* 133901
*
* A full extended-format time string looks like :
* 13:39:01
*
* Times may be postfixed by a timezone offset. This can be either 'Z' for
* UTC, or a string like -0500 or +11:00.
*
* @param string $date
*
* @return array
*/
static function parseVCardTime($date) {
$regex = '/^
(?<hour> [0-9]{2} | -)
(?<minute> [0-9]{2} | -)?
(?<second> [0-9]{2})?
(?: \.[0-9]{3})? # milliseconds
(?P<timezone> # timezone offset
Z | (?: \+|-)(?: [0-9]{4})
)?
$/x';
if (!preg_match($regex, $date, $matches)) {
// Attempting to parse the extended format.
$regex = '/^
(?: (?<hour> [0-9]{2}) : | -)
(?: (?<minute> [0-9]{2}) : | -)?
(?<second> [0-9]{2})?
(?: \.[0-9]{3})? # milliseconds
(?P<timezone> # timezone offset
Z | (?: \+|-)(?: [0-9]{2}:[0-9]{2})
)?
$/x';
if (!preg_match($regex, $date, $matches)) {
throw new InvalidDataException('Invalid vCard time string: ' . $date);
}
}
$parts = [
'hour',
'minute',
'second',
'timezone'
];
$result = [];
foreach ($parts as $part) {
if (empty($matches[$part])) {
$result[$part] = null;
} elseif ($matches[$part] === '-') {
$result[$part] = null;
} else {
$result[$part] = $matches[$part];
}
}
return $result;
}
/**
* This method parses a vCard date and or time value.
*
* This can be used for the DATE, DATE-TIME and
* DATE-AND-OR-TIME value.
*
* This method returns an array, not a DateTime value.
* The elements in the array are in the following order:
* year, month, date, hour, minute, second, timezone
* Almost any part of the string may be omitted. It's for example legal to
* just specify seconds, leave out the year, etc.
*
* Timezone is either returned as 'Z' or as '+0800'
*
* For any non-specified values null is returned.
*
* List of date formats that are supported:
* 20150128
* 2015-01
* --01
* --0128
* ---28
*
* List of supported time formats:
* 13
* 1353
* 135301
* -53
* -5301
* --01 (unreachable, see the tests)
* --01Z
* --01+1234
*
* List of supported date-time formats:
* 20150128T13
* --0128T13
* ---28T13
* ---28T1353
* ---28T135301
* ---28T13Z
* ---28T13+1234
*
* See the regular expressions for all the possible patterns.
*
* Times may be postfixed by a timezone offset. This can be either 'Z' for
* UTC, or a string like -0500 or +1100.
*
* @param string $date
*
* @return array
*/
static function parseVCardDateAndOrTime($date) {
// \d{8}|\d{4}-\d\d|--\d\d(\d\d)?|---\d\d
$valueDate = '/^(?J)(?:' .
'(?<year>\d{4})(?<month>\d\d)(?<date>\d\d)' .
'|(?<year>\d{4})-(?<month>\d\d)' .
'|--(?<month>\d\d)(?<date>\d\d)?' .
'|---(?<date>\d\d)' .
')$/';
// (\d\d(\d\d(\d\d)?)?|-\d\d(\d\d)?|--\d\d)(Z|[+\-]\d\d(\d\d)?)?
$valueTime = '/^(?J)(?:' .
'((?<hour>\d\d)((?<minute>\d\d)(?<second>\d\d)?)?' .
'|-(?<minute>\d\d)(?<second>\d\d)?' .
'|--(?<second>\d\d))' .
'(?<timezone>(Z|[+\-]\d\d(\d\d)?))?' .
')$/';
// (\d{8}|--\d{4}|---\d\d)T\d\d(\d\d(\d\d)?)?(Z|[+\-]\d\d(\d\d?)?
$valueDateTime = '/^(?:' .
'((?<year0>\d{4})(?<month0>\d\d)(?<date0>\d\d)' .
'|--(?<month1>\d\d)(?<date1>\d\d)' .
'|---(?<date2>\d\d))' .
'T' .
'(?<hour>\d\d)((?<minute>\d\d)(?<second>\d\d)?)?' .
'(?<timezone>(Z|[+\-]\d\d(\d\d?)))?' .
')$/';
// date-and-or-time is date | date-time | time
// in this strict order.
if (0 === preg_match($valueDate, $date, $matches)
&& 0 === preg_match($valueDateTime, $date, $matches)
&& 0 === preg_match($valueTime, $date, $matches)) {
throw new InvalidDataException('Invalid vCard date-time string: ' . $date);
}
$parts = [
'year' => null,
'month' => null,
'date' => null,
'hour' => null,
'minute' => null,
'second' => null,
'timezone' => null
];
// The $valueDateTime expression has a bug with (?J) so we simulate it.
$parts['date0'] = &$parts['date'];
$parts['date1'] = &$parts['date'];
$parts['date2'] = &$parts['date'];
$parts['month0'] = &$parts['month'];
$parts['month1'] = &$parts['month'];
$parts['year0'] = &$parts['year'];
foreach ($parts as $part => &$value) {
if (!empty($matches[$part])) {
$value = $matches[$part];
}
}
unset($parts['date0']);
unset($parts['date1']);
unset($parts['date2']);
unset($parts['month0']);
unset($parts['month1']);
unset($parts['year0']);
return $parts;
}
}