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