RRuleIterator.php 35 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079
  1. <?php
  2. namespace Sabre\VObject\Recur;
  3. use DateTimeImmutable;
  4. use DateTimeInterface;
  5. use Iterator;
  6. use Sabre\VObject\DateTimeParser;
  7. use Sabre\VObject\InvalidDataException;
  8. use Sabre\VObject\Property;
  9. /**
  10. * RRuleParser.
  11. *
  12. * This class receives an RRULE string, and allows you to iterate to get a list
  13. * of dates in that recurrence.
  14. *
  15. * For instance, passing: FREQ=DAILY;LIMIT=5 will cause the iterator to contain
  16. * 5 items, one for each day.
  17. *
  18. * @copyright Copyright (C) fruux GmbH (https://fruux.com/)
  19. * @author Evert Pot (http://evertpot.com/)
  20. * @license http://sabre.io/license/ Modified BSD License
  21. */
  22. class RRuleIterator implements Iterator
  23. {
  24. /**
  25. * Constant denoting the upper limit on how long into the future
  26. * we want to iterate. The value is a unix timestamp and currently
  27. * corresponds to the datetime 9999-12-31 11:59:59 UTC.
  28. */
  29. const dateUpperLimit = 253402300799;
  30. /**
  31. * Creates the Iterator.
  32. *
  33. * @param string|array $rrule
  34. */
  35. public function __construct($rrule, DateTimeInterface $start)
  36. {
  37. $this->startDate = $start;
  38. $this->parseRRule($rrule);
  39. $this->currentDate = clone $this->startDate;
  40. }
  41. /* Implementation of the Iterator interface {{{ */
  42. #[\ReturnTypeWillChange]
  43. public function current()
  44. {
  45. if (!$this->valid()) {
  46. return;
  47. }
  48. return clone $this->currentDate;
  49. }
  50. /**
  51. * Returns the current item number.
  52. *
  53. * @return int
  54. */
  55. #[\ReturnTypeWillChange]
  56. public function key()
  57. {
  58. return $this->counter;
  59. }
  60. /**
  61. * Returns whether the current item is a valid item for the recurrence
  62. * iterator. This will return false if we've gone beyond the UNTIL or COUNT
  63. * statements.
  64. *
  65. * @return bool
  66. */
  67. #[\ReturnTypeWillChange]
  68. public function valid()
  69. {
  70. if (null === $this->currentDate) {
  71. return false;
  72. }
  73. if (!is_null($this->count)) {
  74. return $this->counter < $this->count;
  75. }
  76. return is_null($this->until) || $this->currentDate <= $this->until;
  77. }
  78. /**
  79. * Resets the iterator.
  80. *
  81. * @return void
  82. */
  83. #[\ReturnTypeWillChange]
  84. public function rewind()
  85. {
  86. $this->currentDate = clone $this->startDate;
  87. $this->counter = 0;
  88. }
  89. /**
  90. * Goes on to the next iteration.
  91. *
  92. * @return void
  93. */
  94. #[\ReturnTypeWillChange]
  95. public function next()
  96. {
  97. // Otherwise, we find the next event in the normal RRULE
  98. // sequence.
  99. switch ($this->frequency) {
  100. case 'hourly':
  101. $this->nextHourly();
  102. break;
  103. case 'daily':
  104. $this->nextDaily();
  105. break;
  106. case 'weekly':
  107. $this->nextWeekly();
  108. break;
  109. case 'monthly':
  110. $this->nextMonthly();
  111. break;
  112. case 'yearly':
  113. $this->nextYearly();
  114. break;
  115. }
  116. ++$this->counter;
  117. }
  118. /* End of Iterator implementation }}} */
  119. /**
  120. * Returns true if this recurring event never ends.
  121. *
  122. * @return bool
  123. */
  124. public function isInfinite()
  125. {
  126. return !$this->count && !$this->until;
  127. }
  128. /**
  129. * This method allows you to quickly go to the next occurrence after the
  130. * specified date.
  131. */
  132. public function fastForward(DateTimeInterface $dt)
  133. {
  134. while ($this->valid() && $this->currentDate < $dt) {
  135. $this->next();
  136. }
  137. }
  138. /**
  139. * The reference start date/time for the rrule.
  140. *
  141. * All calculations are based on this initial date.
  142. *
  143. * @var DateTimeInterface
  144. */
  145. protected $startDate;
  146. /**
  147. * The date of the current iteration. You can get this by calling
  148. * ->current().
  149. *
  150. * @var DateTimeInterface
  151. */
  152. protected $currentDate;
  153. /**
  154. * The number of hours that the next occurrence of an event
  155. * jumped forward, usually because summer time started and
  156. * the requested time-of-day like 0230 did not exist on that
  157. * day. And so the event was scheduled 1 hour later at 0330.
  158. */
  159. protected $hourJump = 0;
  160. /**
  161. * Frequency is one of: secondly, minutely, hourly, daily, weekly, monthly,
  162. * yearly.
  163. *
  164. * @var string
  165. */
  166. protected $frequency;
  167. /**
  168. * The number of recurrences, or 'null' if infinitely recurring.
  169. *
  170. * @var int
  171. */
  172. protected $count;
  173. /**
  174. * The interval.
  175. *
  176. * If for example frequency is set to daily, interval = 2 would mean every
  177. * 2 days.
  178. *
  179. * @var int
  180. */
  181. protected $interval = 1;
  182. /**
  183. * The last instance of this recurrence, inclusively.
  184. *
  185. * @var DateTimeInterface|null
  186. */
  187. protected $until;
  188. /**
  189. * Which seconds to recur.
  190. *
  191. * This is an array of integers (between 0 and 60)
  192. *
  193. * @var array
  194. */
  195. protected $bySecond;
  196. /**
  197. * Which minutes to recur.
  198. *
  199. * This is an array of integers (between 0 and 59)
  200. *
  201. * @var array
  202. */
  203. protected $byMinute;
  204. /**
  205. * Which hours to recur.
  206. *
  207. * This is an array of integers (between 0 and 23)
  208. *
  209. * @var array
  210. */
  211. protected $byHour;
  212. /**
  213. * The current item in the list.
  214. *
  215. * You can get this number with the key() method.
  216. *
  217. * @var int
  218. */
  219. protected $counter = 0;
  220. /**
  221. * Which weekdays to recur.
  222. *
  223. * This is an array of weekdays
  224. *
  225. * This may also be preceded by a positive or negative integer. If present,
  226. * this indicates the nth occurrence of a specific day within the monthly or
  227. * yearly rrule. For instance, -2TU indicates the second-last tuesday of
  228. * the month, or year.
  229. *
  230. * @var array
  231. */
  232. protected $byDay;
  233. /**
  234. * Which days of the month to recur.
  235. *
  236. * This is an array of days of the months (1-31). The value can also be
  237. * negative. -5 for instance means the 5th last day of the month.
  238. *
  239. * @var array
  240. */
  241. protected $byMonthDay;
  242. /**
  243. * Which days of the year to recur.
  244. *
  245. * This is an array with days of the year (1 to 366). The values can also
  246. * be negative. For instance, -1 will always represent the last day of the
  247. * year. (December 31st).
  248. *
  249. * @var array
  250. */
  251. protected $byYearDay;
  252. /**
  253. * Which week numbers to recur.
  254. *
  255. * This is an array of integers from 1 to 53. The values can also be
  256. * negative. -1 will always refer to the last week of the year.
  257. *
  258. * @var array
  259. */
  260. protected $byWeekNo;
  261. /**
  262. * Which months to recur.
  263. *
  264. * This is an array of integers from 1 to 12.
  265. *
  266. * @var array
  267. */
  268. protected $byMonth;
  269. /**
  270. * Which items in an existing st to recur.
  271. *
  272. * These numbers work together with an existing by* rule. It specifies
  273. * exactly which items of the existing by-rule to filter.
  274. *
  275. * Valid values are 1 to 366 and -1 to -366. As an example, this can be
  276. * used to recur the last workday of the month.
  277. *
  278. * This would be done by setting frequency to 'monthly', byDay to
  279. * 'MO,TU,WE,TH,FR' and bySetPos to -1.
  280. *
  281. * @var array
  282. */
  283. protected $bySetPos;
  284. /**
  285. * When the week starts.
  286. *
  287. * @var string
  288. */
  289. protected $weekStart = 'MO';
  290. /* Functions that advance the iterator {{{ */
  291. /**
  292. * Gets the original start time of the RRULE.
  293. *
  294. * The value is formatted as a string with 24-hour:minute:second
  295. */
  296. protected function startTime(): string
  297. {
  298. return $this->startDate->format('H:i:s');
  299. }
  300. /**
  301. * Advances currentDate by the interval.
  302. * The time is set from the original startDate.
  303. * If the recurrence is on a day when summer time started, then the
  304. * time on that day may have jumped forward, for example, from 0230 to 0330.
  305. * Using the original time means that the next recurrence will be calculated
  306. * based on the original start time and the day/week/month/year interval.
  307. * So the start time of the next occurrence can correctly revert to 0230.
  308. */
  309. protected function advanceTheDate(string $interval): void
  310. {
  311. $this->currentDate = $this->currentDate->modify($interval.' '.$this->startTime());
  312. }
  313. /**
  314. * Does the processing for adjusting the time of multi-hourly events when summer time starts.
  315. */
  316. protected function adjustForTimeJumpsOfHourlyEvent(DateTimeInterface $previousEventDateTime): void
  317. {
  318. if (0 === $this->hourJump) {
  319. // Remember if the clock time jumped forward on the next occurrence.
  320. // That happens if the next event time is on a day when summer time starts
  321. // and the event time is in the non-existent hour of the day.
  322. // For example, an event that normally starts at 02:30 will
  323. // have to start at 03:30 on that day.
  324. // If the interval is just 1 hour, then there is no "jumping back" to do.
  325. // The events that day will happen, for example, at 0030 0130 0330 0430 0530...
  326. if ($this->interval > 1) {
  327. $expectedHourOfNextDate = ((int) $previousEventDateTime->format('G') + $this->interval) % 24;
  328. $actualHourOfNextDate = (int) $this->currentDate->format('G');
  329. $this->hourJump = $actualHourOfNextDate - $expectedHourOfNextDate;
  330. }
  331. } else {
  332. // The hour "jumped" for the previous occurrence, to avoid the non-existent time.
  333. // currentDate got set ahead by (usually) 1 hour on that day.
  334. // Adjust it back for this next occurrence.
  335. $this->currentDate = $this->currentDate->sub(new \DateInterval('PT'.$this->hourJump.'H'));
  336. $this->hourJump = 0;
  337. }
  338. }
  339. /**
  340. * Does the processing for advancing the iterator for hourly frequency.
  341. */
  342. protected function nextHourly()
  343. {
  344. $previousEventDateTime = clone $this->currentDate;
  345. $this->currentDate = $this->currentDate->modify('+'.$this->interval.' hours');
  346. $this->adjustForTimeJumpsOfHourlyEvent($previousEventDateTime);
  347. }
  348. /**
  349. * Does the processing for advancing the iterator for daily frequency.
  350. */
  351. protected function nextDaily()
  352. {
  353. if (!$this->byHour && !$this->byDay) {
  354. $this->advanceTheDate('+'.$this->interval.' days');
  355. return;
  356. }
  357. $recurrenceHours = [];
  358. if (!empty($this->byHour)) {
  359. $recurrenceHours = $this->getHours();
  360. }
  361. $recurrenceDays = [];
  362. if (!empty($this->byDay)) {
  363. $recurrenceDays = $this->getDays();
  364. }
  365. $recurrenceMonths = [];
  366. if (!empty($this->byMonth)) {
  367. $recurrenceMonths = $this->getMonths();
  368. }
  369. do {
  370. if ($this->byHour) {
  371. if ('23' == $this->currentDate->format('G')) {
  372. // to obey the interval rule
  373. $this->currentDate = $this->currentDate->modify('+'.($this->interval - 1).' days');
  374. }
  375. $this->currentDate = $this->currentDate->modify('+1 hours');
  376. } else {
  377. $this->currentDate = $this->currentDate->modify('+'.$this->interval.' days');
  378. }
  379. // Current month of the year
  380. $currentMonth = $this->currentDate->format('n');
  381. // Current day of the week
  382. $currentDay = $this->currentDate->format('w');
  383. // Current hour of the day
  384. $currentHour = $this->currentDate->format('G');
  385. if ($this->currentDate->getTimestamp() > self::dateUpperLimit) {
  386. $this->currentDate = null;
  387. return;
  388. }
  389. } while (
  390. ($this->byDay && !in_array($currentDay, $recurrenceDays)) ||
  391. ($this->byHour && !in_array($currentHour, $recurrenceHours)) ||
  392. ($this->byMonth && !in_array($currentMonth, $recurrenceMonths))
  393. );
  394. }
  395. /**
  396. * Does the processing for advancing the iterator for weekly frequency.
  397. */
  398. protected function nextWeekly()
  399. {
  400. if (!$this->byHour && !$this->byDay) {
  401. $this->advanceTheDate('+'.$this->interval.' weeks');
  402. return;
  403. }
  404. $recurrenceHours = [];
  405. if ($this->byHour) {
  406. $recurrenceHours = $this->getHours();
  407. }
  408. $recurrenceDays = [];
  409. if ($this->byDay) {
  410. $recurrenceDays = $this->getDays();
  411. }
  412. // First day of the week:
  413. $firstDay = $this->dayMap[$this->weekStart];
  414. do {
  415. if ($this->byHour) {
  416. $this->currentDate = $this->currentDate->modify('+1 hours');
  417. } else {
  418. $this->advanceTheDate('+1 days');
  419. }
  420. // Current day of the week
  421. $currentDay = (int) $this->currentDate->format('w');
  422. // Current hour of the day
  423. $currentHour = (int) $this->currentDate->format('G');
  424. // We need to roll over to the next week
  425. if ($currentDay === $firstDay && (!$this->byHour || '0' == $currentHour)) {
  426. $this->currentDate = $this->currentDate->modify('+'.($this->interval - 1).' weeks');
  427. // We need to go to the first day of this week, but only if we
  428. // are not already on this first day of this week.
  429. if ($this->currentDate->format('w') != $firstDay) {
  430. $this->currentDate = $this->currentDate->modify('last '.$this->dayNames[$this->dayMap[$this->weekStart]]);
  431. }
  432. }
  433. // We have a match
  434. } while (($this->byDay && !in_array($currentDay, $recurrenceDays)) || ($this->byHour && !in_array($currentHour, $recurrenceHours)));
  435. }
  436. /**
  437. * Does the processing for advancing the iterator for monthly frequency.
  438. */
  439. protected function nextMonthly()
  440. {
  441. $currentDayOfMonth = $this->currentDate->format('j');
  442. if (!$this->byMonthDay && !$this->byDay) {
  443. // If the current day is higher than the 28th, rollover can
  444. // occur to the next month. We Must skip these invalid
  445. // entries.
  446. if ($currentDayOfMonth < 29) {
  447. $this->advanceTheDate('+'.$this->interval.' months');
  448. } else {
  449. $increase = 0;
  450. do {
  451. ++$increase;
  452. $tempDate = clone $this->currentDate;
  453. $tempDate = $tempDate->modify('+ '.($this->interval * $increase).' months '.$this->startTime());
  454. } while ($tempDate->format('j') != $currentDayOfMonth);
  455. $this->currentDate = $tempDate;
  456. }
  457. return;
  458. }
  459. $occurrence = -1;
  460. while (true) {
  461. $occurrences = $this->getMonthlyOccurrences();
  462. foreach ($occurrences as $occurrence) {
  463. // The first occurrence thats higher than the current
  464. // day of the month wins.
  465. if ($occurrence > $currentDayOfMonth) {
  466. break 2;
  467. }
  468. }
  469. // If we made it all the way here, it means there were no
  470. // valid occurrences, and we need to advance to the next
  471. // month.
  472. //
  473. // This line does not currently work in hhvm. Temporary workaround
  474. // follows:
  475. // $this->currentDate->modify('first day of this month');
  476. $this->currentDate = new DateTimeImmutable($this->currentDate->format('Y-m-1 H:i:s'), $this->currentDate->getTimezone());
  477. // end of workaround
  478. $this->currentDate = $this->currentDate->modify('+ '.$this->interval.' months');
  479. // This goes to 0 because we need to start counting at the
  480. // beginning.
  481. $currentDayOfMonth = 0;
  482. // For some reason the "until" parameter was not being used here,
  483. // that's why the workaround of the 10000 year bug was needed at all
  484. // let's stop it before the "until" parameter date
  485. if ($this->until && $this->currentDate->getTimestamp() >= $this->until->getTimestamp()) {
  486. return;
  487. }
  488. // To prevent running this forever (better: until we hit the max date of DateTimeImmutable) we simply
  489. // stop at 9999-12-31. Looks like the year 10000 problem is not solved in php ....
  490. if ($this->currentDate->getTimestamp() > self::dateUpperLimit) {
  491. $this->currentDate = null;
  492. return;
  493. }
  494. }
  495. // Set the currentDate to the year and month that we are in, and the day of the month that we have selected.
  496. // That day could be a day when summer time starts, and if the time of the event is, for example, 0230,
  497. // then 0230 will not be a valid time on that day. So always apply the start time from the original startDate.
  498. // The "modify" method will set the time forward to 0330, for example, if needed.
  499. $this->currentDate = $this->currentDate->setDate(
  500. (int) $this->currentDate->format('Y'),
  501. (int) $this->currentDate->format('n'),
  502. (int) $occurrence
  503. )->modify($this->startTime());
  504. }
  505. /**
  506. * Does the processing for advancing the iterator for yearly frequency.
  507. */
  508. protected function nextYearly()
  509. {
  510. $currentMonth = $this->currentDate->format('n');
  511. $currentYear = $this->currentDate->format('Y');
  512. $currentDayOfMonth = $this->currentDate->format('j');
  513. // No sub-rules, so we just advance by year
  514. if (empty($this->byMonth)) {
  515. // Unless it was a leap day!
  516. if (2 == $currentMonth && 29 == $currentDayOfMonth) {
  517. $counter = 0;
  518. do {
  519. ++$counter;
  520. // Here we increase the year count by the interval, until
  521. // we hit a date that's also in a leap year.
  522. //
  523. // We could just find the next interval that's dividable by
  524. // 4, but that would ignore the rule that there's no leap
  525. // year every year that's dividable by a 100, but not by
  526. // 400. (1800, 1900, 2100). So we just rely on the datetime
  527. // functions instead.
  528. $nextDate = clone $this->currentDate;
  529. $nextDate = $nextDate->modify('+ '.($this->interval * $counter).' years');
  530. } while (2 != $nextDate->format('n'));
  531. $this->currentDate = $nextDate;
  532. return;
  533. }
  534. if (null !== $this->byWeekNo) { // byWeekNo is an array with values from -53 to -1, or 1 to 53
  535. $dayOffsets = [];
  536. if ($this->byDay) {
  537. foreach ($this->byDay as $byDay) {
  538. $dayOffsets[] = $this->dayMap[$byDay];
  539. }
  540. } else { // default is Monday
  541. $dayOffsets[] = 1;
  542. }
  543. $currentYear = $this->currentDate->format('Y');
  544. while (true) {
  545. $checkDates = [];
  546. // loop through all WeekNo and Days to check all the combinations
  547. foreach ($this->byWeekNo as $byWeekNo) {
  548. foreach ($dayOffsets as $dayOffset) {
  549. $date = clone $this->currentDate;
  550. $date = $date->setISODate($currentYear, $byWeekNo, $dayOffset);
  551. if ($date > $this->currentDate) {
  552. $checkDates[] = $date;
  553. }
  554. }
  555. }
  556. if (count($checkDates) > 0) {
  557. $this->currentDate = min($checkDates);
  558. return;
  559. }
  560. // if there is no date found, check the next year
  561. $currentYear += $this->interval;
  562. }
  563. }
  564. if (null !== $this->byYearDay) { // byYearDay is an array with values from -366 to -1, or 1 to 366
  565. $dayOffsets = [];
  566. if ($this->byDay) {
  567. foreach ($this->byDay as $byDay) {
  568. $dayOffsets[] = $this->dayMap[$byDay];
  569. }
  570. } else { // default is Monday-Sunday
  571. $dayOffsets = [1, 2, 3, 4, 5, 6, 7];
  572. }
  573. $currentYear = $this->currentDate->format('Y');
  574. while (true) {
  575. $checkDates = [];
  576. // loop through all YearDay and Days to check all the combinations
  577. foreach ($this->byYearDay as $byYearDay) {
  578. $date = clone $this->currentDate;
  579. if ($byYearDay > 0) {
  580. $date = $date->setDate($currentYear, 1, 1);
  581. $date = $date->add(new \DateInterval('P'.($byYearDay - 1).'D'));
  582. } else {
  583. $date = $date->setDate($currentYear, 12, 31);
  584. $date = $date->sub(new \DateInterval('P'.abs($byYearDay + 1).'D'));
  585. }
  586. if ($date > $this->currentDate && in_array($date->format('N'), $dayOffsets)) {
  587. $checkDates[] = $date;
  588. }
  589. }
  590. if (count($checkDates) > 0) {
  591. $this->currentDate = min($checkDates);
  592. return;
  593. }
  594. // if there is no date found, check the next year
  595. $currentYear += $this->interval;
  596. }
  597. }
  598. // The easiest form
  599. $this->advanceTheDate('+'.$this->interval.' years');
  600. return;
  601. }
  602. $currentMonth = $this->currentDate->format('n');
  603. $currentYear = $this->currentDate->format('Y');
  604. $currentDayOfMonth = $this->currentDate->format('j');
  605. $advancedToNewMonth = false;
  606. // If we got a byDay or getMonthDay filter, we must first expand
  607. // further.
  608. if ($this->byDay || $this->byMonthDay) {
  609. $occurrence = -1;
  610. while (true) {
  611. $occurrences = $this->getMonthlyOccurrences();
  612. foreach ($occurrences as $occurrence) {
  613. // The first occurrence that's higher than the current
  614. // day of the month wins.
  615. // If we advanced to the next month or year, the first
  616. // occurrence is always correct.
  617. if ($occurrence > $currentDayOfMonth || $advancedToNewMonth) {
  618. // only consider byMonth matches,
  619. // otherwise, we don't follow RRule correctly
  620. if (in_array($currentMonth, $this->byMonth)) {
  621. break 2;
  622. }
  623. }
  624. }
  625. // If we made it here, it means we need to advance to
  626. // the next month or year.
  627. $currentDayOfMonth = 1;
  628. $advancedToNewMonth = true;
  629. do {
  630. ++$currentMonth;
  631. if ($currentMonth > 12) {
  632. $currentYear += $this->interval;
  633. $currentMonth = 1;
  634. }
  635. } while (!in_array($currentMonth, $this->byMonth));
  636. $this->currentDate = $this->currentDate->setDate(
  637. (int) $currentYear,
  638. (int) $currentMonth,
  639. (int) $currentDayOfMonth
  640. );
  641. // To prevent running this forever (better: until we hit the max date of DateTimeImmutable) we simply
  642. // stop at 9999-12-31. Looks like the year 10000 problem is not solved in php ....
  643. if ($this->currentDate->getTimestamp() > self::dateUpperLimit) {
  644. $this->currentDate = null;
  645. return;
  646. }
  647. }
  648. // If we made it here, it means we got a valid occurrence
  649. $this->currentDate = $this->currentDate->setDate(
  650. (int) $currentYear,
  651. (int) $currentMonth,
  652. (int) $occurrence
  653. )->modify($this->startTime());
  654. return;
  655. } else {
  656. // These are the 'byMonth' rules, if there are no byDay or
  657. // byMonthDay sub-rules.
  658. do {
  659. ++$currentMonth;
  660. if ($currentMonth > 12) {
  661. $currentYear += $this->interval;
  662. $currentMonth = 1;
  663. }
  664. } while (!in_array($currentMonth, $this->byMonth));
  665. $this->currentDate = $this->currentDate->setDate(
  666. (int) $currentYear,
  667. (int) $currentMonth,
  668. (int) $currentDayOfMonth
  669. )->modify($this->startTime());
  670. return;
  671. }
  672. }
  673. /* }}} */
  674. /**
  675. * This method receives a string from an RRULE property, and populates this
  676. * class with all the values.
  677. *
  678. * @param string|array $rrule
  679. */
  680. protected function parseRRule($rrule)
  681. {
  682. if (is_string($rrule)) {
  683. $rrule = Property\ICalendar\Recur::stringToArray($rrule);
  684. }
  685. foreach ($rrule as $key => $value) {
  686. $key = strtoupper($key);
  687. switch ($key) {
  688. case 'FREQ':
  689. $value = strtolower($value);
  690. if (!in_array(
  691. $value,
  692. ['secondly', 'minutely', 'hourly', 'daily', 'weekly', 'monthly', 'yearly']
  693. )) {
  694. throw new InvalidDataException('Unknown value for FREQ='.strtoupper($value));
  695. }
  696. $this->frequency = $value;
  697. break;
  698. case 'UNTIL':
  699. $this->until = DateTimeParser::parse($value, $this->startDate->getTimezone());
  700. // In some cases events are generated with an UNTIL=
  701. // parameter before the actual start of the event.
  702. //
  703. // Not sure why this is happening. We assume that the
  704. // intention was that the event only recurs once.
  705. //
  706. // So we are modifying the parameter so our code doesn't
  707. // break.
  708. if ($this->until < $this->startDate) {
  709. $this->until = $this->startDate;
  710. }
  711. break;
  712. case 'INTERVAL':
  713. case 'COUNT':
  714. $val = (int) $value;
  715. if ($val < 1) {
  716. throw new InvalidDataException(strtoupper($key).' in RRULE must be a positive integer!');
  717. }
  718. $key = strtolower($key);
  719. $this->$key = $val;
  720. break;
  721. case 'BYSECOND':
  722. $this->bySecond = (array) $value;
  723. break;
  724. case 'BYMINUTE':
  725. $this->byMinute = (array) $value;
  726. break;
  727. case 'BYHOUR':
  728. $this->byHour = (array) $value;
  729. break;
  730. case 'BYDAY':
  731. $value = (array) $value;
  732. foreach ($value as $part) {
  733. if (!preg_match('#^ (-|\+)? ([1-5])? (MO|TU|WE|TH|FR|SA|SU) $# xi', $part)) {
  734. throw new InvalidDataException('Invalid part in BYDAY clause: '.$part);
  735. }
  736. }
  737. $this->byDay = $value;
  738. break;
  739. case 'BYMONTHDAY':
  740. $this->byMonthDay = (array) $value;
  741. break;
  742. case 'BYYEARDAY':
  743. $this->byYearDay = (array) $value;
  744. foreach ($this->byYearDay as $byYearDay) {
  745. if (!is_numeric($byYearDay) || (int) $byYearDay < -366 || 0 == (int) $byYearDay || (int) $byYearDay > 366) {
  746. throw new InvalidDataException('BYYEARDAY in RRULE must have value(s) from 1 to 366, or -366 to -1!');
  747. }
  748. }
  749. break;
  750. case 'BYWEEKNO':
  751. $this->byWeekNo = (array) $value;
  752. foreach ($this->byWeekNo as $byWeekNo) {
  753. if (!is_numeric($byWeekNo) || (int) $byWeekNo < -53 || 0 == (int) $byWeekNo || (int) $byWeekNo > 53) {
  754. throw new InvalidDataException('BYWEEKNO in RRULE must have value(s) from 1 to 53, or -53 to -1!');
  755. }
  756. }
  757. break;
  758. case 'BYMONTH':
  759. $this->byMonth = (array) $value;
  760. foreach ($this->byMonth as $byMonth) {
  761. if (!is_numeric($byMonth) || (int) $byMonth < 1 || (int) $byMonth > 12) {
  762. throw new InvalidDataException('BYMONTH in RRULE must have value(s) between 1 and 12!');
  763. }
  764. }
  765. break;
  766. case 'BYSETPOS':
  767. $this->bySetPos = (array) $value;
  768. break;
  769. case 'WKST':
  770. $this->weekStart = strtoupper($value);
  771. break;
  772. default:
  773. throw new InvalidDataException('Not supported: '.strtoupper($key));
  774. }
  775. }
  776. }
  777. /**
  778. * Mappings between the day number and english day name.
  779. *
  780. * @var array
  781. */
  782. protected $dayNames = [
  783. 0 => 'Sunday',
  784. 1 => 'Monday',
  785. 2 => 'Tuesday',
  786. 3 => 'Wednesday',
  787. 4 => 'Thursday',
  788. 5 => 'Friday',
  789. 6 => 'Saturday',
  790. ];
  791. /**
  792. * Returns all the occurrences for a monthly frequency with a 'byDay' or
  793. * 'byMonthDay' expansion for the current month.
  794. *
  795. * The returned list is an array of integers with the day of month (1-31).
  796. *
  797. * @return array
  798. */
  799. protected function getMonthlyOccurrences()
  800. {
  801. $startDate = clone $this->currentDate;
  802. $byDayResults = [];
  803. // Our strategy is to simply go through the byDays, advance the date to
  804. // that point and add it to the results.
  805. if ($this->byDay) {
  806. foreach ($this->byDay as $day) {
  807. $dayName = $this->dayNames[$this->dayMap[substr($day, -2)]];
  808. // Dayname will be something like 'wednesday'. Now we need to find
  809. // all wednesdays in this month.
  810. $dayHits = [];
  811. // workaround for missing 'first day of the month' support in hhvm
  812. $checkDate = new \DateTime($startDate->format('Y-m-1'));
  813. // workaround modify always advancing the date even if the current day is a $dayName in hhvm
  814. if ($checkDate->format('l') !== $dayName) {
  815. $checkDate = $checkDate->modify($dayName);
  816. }
  817. do {
  818. $dayHits[] = $checkDate->format('j');
  819. $checkDate = $checkDate->modify('next '.$dayName);
  820. } while ($checkDate->format('n') === $startDate->format('n'));
  821. // So now we have 'all wednesdays' for month. It is however
  822. // possible that the user only really wanted the 1st, 2nd or last
  823. // wednesday.
  824. if (strlen($day) > 2) {
  825. $offset = (int) substr($day, 0, -2);
  826. if ($offset > 0) {
  827. // It is possible that the day does not exist, such as a
  828. // 5th or 6th wednesday of the month.
  829. if (isset($dayHits[$offset - 1])) {
  830. $byDayResults[] = $dayHits[$offset - 1];
  831. }
  832. } else {
  833. // if it was negative we count from the end of the array
  834. // might not exist, fx. -5th tuesday
  835. if (isset($dayHits[count($dayHits) + $offset])) {
  836. $byDayResults[] = $dayHits[count($dayHits) + $offset];
  837. }
  838. }
  839. } else {
  840. // There was no counter (first, second, last wednesdays), so we
  841. // just need to add the all to the list).
  842. $byDayResults = array_merge($byDayResults, $dayHits);
  843. }
  844. }
  845. }
  846. $byMonthDayResults = [];
  847. if ($this->byMonthDay) {
  848. foreach ($this->byMonthDay as $monthDay) {
  849. // Removing values that are out of range for this month
  850. if ($monthDay > $startDate->format('t') ||
  851. $monthDay < 0 - $startDate->format('t')) {
  852. continue;
  853. }
  854. if ($monthDay > 0) {
  855. $byMonthDayResults[] = $monthDay;
  856. } else {
  857. // Negative values
  858. $byMonthDayResults[] = $startDate->format('t') + 1 + $monthDay;
  859. }
  860. }
  861. }
  862. // If there was just byDay or just byMonthDay, they just specify our
  863. // (almost) final list. If both were provided, then byDay limits the
  864. // list.
  865. if ($this->byMonthDay && $this->byDay) {
  866. $result = array_intersect($byMonthDayResults, $byDayResults);
  867. } elseif ($this->byMonthDay) {
  868. $result = $byMonthDayResults;
  869. } else {
  870. $result = $byDayResults;
  871. }
  872. $result = array_unique($result);
  873. sort($result, SORT_NUMERIC);
  874. // The last thing that needs checking is the BYSETPOS. If it's set, it
  875. // means only certain items in the set survive the filter.
  876. if (!$this->bySetPos) {
  877. return $result;
  878. }
  879. $filteredResult = [];
  880. foreach ($this->bySetPos as $setPos) {
  881. if ($setPos < 0) {
  882. $setPos = count($result) + ($setPos + 1);
  883. }
  884. if (isset($result[$setPos - 1])) {
  885. $filteredResult[] = $result[$setPos - 1];
  886. }
  887. }
  888. sort($filteredResult, SORT_NUMERIC);
  889. return $filteredResult;
  890. }
  891. /**
  892. * Simple mapping from iCalendar day names to day numbers.
  893. *
  894. * @var array
  895. */
  896. protected $dayMap = [
  897. 'SU' => 0,
  898. 'MO' => 1,
  899. 'TU' => 2,
  900. 'WE' => 3,
  901. 'TH' => 4,
  902. 'FR' => 5,
  903. 'SA' => 6,
  904. ];
  905. protected function getHours()
  906. {
  907. $recurrenceHours = [];
  908. foreach ($this->byHour as $byHour) {
  909. $recurrenceHours[] = $byHour;
  910. }
  911. return $recurrenceHours;
  912. }
  913. protected function getDays()
  914. {
  915. $recurrenceDays = [];
  916. foreach ($this->byDay as $byDay) {
  917. // The day may be preceded with a positive (+n) or
  918. // negative (-n) integer. However, this does not make
  919. // sense in 'weekly' so we ignore it here.
  920. $recurrenceDays[] = $this->dayMap[substr($byDay, -2)];
  921. }
  922. return $recurrenceDays;
  923. }
  924. protected function getMonths()
  925. {
  926. $recurrenceMonths = [];
  927. foreach ($this->byMonth as $byMonth) {
  928. $recurrenceMonths[] = $byMonth;
  929. }
  930. return $recurrenceMonths;
  931. }
  932. }