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