DateTimeParser.php 16 KB

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