PluginProbe
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution / 1.5.23
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution v1.5.23
2.4.0 2.3.0 2.2.5 2.2.0 2.1.2 2.1.1 trunk 1.10.0 1.10.01 1.10.02 1.5.0 1.5.01 1.5.02 1.5.1 1.5.10 1.5.20 1.5.21 1.5.22 1.5.23 1.5.24 1.5.25 1.6.0 1.7.0 1.7.1 1.7.2 All 33 releases
fluent-booking / app / Services / Libs / RRule / RRule.php

RRule.php in Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution 1.5.23, at app/Services/Libs/RRule/RRule.php

2,397 lines 76.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Licensed under the MIT license.
5 *
6 * For the full copyright and license information, please view the LICENSE file.
7 *
8 * @author Rémi Lanvin <remi@cloudconnected.fr>
9 * @link https://github.com/rlanvin/php-rrule
10 */
11
12 namespace FluentBooking\App\Services\Libs\RRule;
13
14 /**
15 * Check that a variable is not empty.
16 *
17 * 0 and '0' are considered NOT empty.
18 *
19 * @param mixed $var Variable to be checked
20 * @return bool
21 */
22 function not_empty($var)
23 {
24 return ! empty($var) || $var === 0 || $var === '0';
25 }
26
27 /**
28 * Python-like modulo.
29 *
30 * The % operator in PHP returns the remainder of a / b, but differs from
31 * some other languages in that the result will have the same sign as the
32 * dividend. For example, -1 % 8 == -1, whereas in some other languages
33 * (such as Python) the result would be 7. This function emulates the more
34 * correct modulo behavior, which is useful for certain applications such as
35 * calculating an offset index in a circular list.
36 *
37 * @param int $a The dividend.
38 * @param int $b The divisor.
39 *
40 * @return int $a % $b where the result is between 0 and $b
41 * (either 0 <= x < $b
42 * or $b < x <= 0, depending on the sign of $b).
43 *
44 * @copyright 2006 The Closure Library Authors.
45 */
46 function pymod($a, $b)
47 {
48 $x = $a % $b;
49
50 // If $x and $b differ in sign, add $b to wrap the result to the correct sign.
51 return ($x * $b < 0) ? $x + $b : $x;
52 }
53
54 /**
55 * Check if a year is a leap year.
56 *
57 * @param int $year The year to be checked.
58 * @return bool
59 */
60 function is_leap_year($year)
61 {
62 if ($year % 4 !== 0) {
63 return false;
64 }
65 if ($year % 100 !== 0) {
66 return true;
67 }
68 if ($year % 400 !== 0) {
69 return false;
70 }
71 return true;
72 }
73
74 /**
75 * Implementation of RRULE as defined by RFC 5545 (iCalendar).
76 * Heavily based on python-dateutil/rrule
77 *
78 * Some useful terms to understand the algorithms and variables naming:
79 *
80 * - "yearday" = day of the year, from 0 to 365 (on leap years) - `date('z')`
81 * - "weekday" = day of the week (ISO-8601), from 1 (MO) to 7 (SU) - `date('N')`
82 * - "monthday" = day of the month, from 1 to 31
83 * - "wkst" = week start, the weekday (1 to 7) which is the first day of week.
84 * Default is Monday (1). In some countries it's Sunday (7).
85 * - "weekno" = number of the week in the year (ISO-8601)
86 *
87 * CAREFUL with this bug: https://bugs.php.net/bug.php?id=62476
88 *
89 * @link https://tools.ietf.org/html/rfc5545
90 * @link https://labix.org/python-dateutil
91 */
92 class RRule implements RRuleInterface
93 {
94 use RRuleTrait;
95
96 const SECONDLY = 7;
97 const MINUTELY = 6;
98 const HOURLY = 5;
99 const DAILY = 4;
100 const WEEKLY = 3;
101 const MONTHLY = 2;
102 const YEARLY = 1;
103
104 /**
105 * Frequency names.
106 * Used internally for conversion but public if a reference list is needed.
107 */
108 const FREQUENCIES = array(
109 'SECONDLY' => self::SECONDLY,
110 'MINUTELY' => self::MINUTELY,
111 'HOURLY' => self::HOURLY,
112 'DAILY' => self::DAILY,
113 'WEEKLY' => self::WEEKLY,
114 'MONTHLY' => self::MONTHLY,
115 'YEARLY' => self::YEARLY
116 );
117
118 /**
119 * Weekdays numbered from 1 (ISO-8601 or `date('N')`).
120 * Used internally but public if a reference list is needed.
121 */
122 const WEEKDAYS = array(
123 'MO' => 1,
124 'TU' => 2,
125 'WE' => 3,
126 'TH' => 4,
127 'FR' => 5,
128 'SA' => 6,
129 'SU' => 7
130 );
131
132 /**
133 * @var array original rule
134 */
135 protected $rule = array(
136 'DTSTART' => null,
137 'FREQ' => null,
138 'UNTIL' => null,
139 'COUNT' => null,
140 'INTERVAL' => 1,
141 'BYSECOND' => null,
142 'BYMINUTE' => null,
143 'BYHOUR' => null,
144 'BYDAY' => null,
145 'BYMONTHDAY' => null,
146 'BYYEARDAY' => null,
147 'BYWEEKNO' => null,
148 'BYMONTH' => null,
149 'BYSETPOS' => null,
150 'WKST' => 'MO'
151 );
152
153 // parsed and validated values
154 protected $dtstart = null;
155 protected $freq = null;
156 protected $until = null;
157 protected $count = null;
158 protected $interval = null;
159 protected $bysecond = null;
160 protected $byminute = null;
161 protected $byhour = null;
162 protected $byweekday = null;
163 protected $byweekday_nth = null;
164 protected $bymonthday = null;
165 protected $bymonthday_negative = null;
166 protected $byyearday = null;
167 protected $byweekno = null;
168 protected $bymonth = null;
169 protected $bysetpos = null;
170 protected $wkst = null;
171 protected $timeset = null;
172
173 // cache variables
174 protected $total = null;
175 protected $cache = array();
176
177 ///////////////////////////////////////////////////////////////////////////////
178 // Public interface
179
180 /**
181 * The constructor needs the entire rule at once.
182 * There is no setter after the class has been instanciated,
183 * because in order to validate some BYXXX parts, we need to know
184 * the value of some other parts (FREQ or other BXXX parts).
185 *
186 * @param mixed $parts An assoc array of parts, or a RFC string.
187 */
188 public function __construct($parts, $dtstart = null)
189 {
190 if (is_string($parts)) {
191 $parts = RfcParser::parseRRule($parts, $dtstart);
192 $parts = array_change_key_case($parts, CASE_UPPER);
193 }
194 else {
195 if ($dtstart) {
196 throw new \InvalidArgumentException('$dtstart argument has no effect if not constructing from a string');
197 }
198 if (is_array($parts)) {
199 $parts = array_change_key_case($parts, CASE_UPPER);
200 }
201 else {
202 throw new \InvalidArgumentException(sprintf(
203 'The first argument must be a string or an array (%s provided)',
204 esc_html(gettype($parts))
205 ));
206 }
207 }
208
209 // validate extra parts
210 $unsupported = array_diff_key($parts, $this->rule);
211 if (! empty($unsupported)) {
212 throw new \InvalidArgumentException(
213 'Unsupported parameter(s): '
214 . esc_html(implode(',',array_keys($unsupported)))
215 );
216 }
217
218 $parts = array_merge($this->rule, $parts);
219 $this->rule = $parts; // save original rule
220
221 // WKST
222 $parts['WKST'] = strtoupper($parts['WKST']);
223 if (! array_key_exists($parts['WKST'], self::WEEKDAYS)) {
224 throw new \InvalidArgumentException(
225 'The WKST rule part must be one of the following: '
226 .implode(', ',array_keys(self::WEEKDAYS)) // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
227 );
228 }
229 $this->wkst = self::WEEKDAYS[$parts['WKST']];
230
231 // FREQ
232 if (is_integer($parts['FREQ'])) {
233 if ($parts['FREQ'] > self::SECONDLY || $parts['FREQ'] < self::YEARLY) {
234 throw new \InvalidArgumentException(
235 'The FREQ rule part must be one of the following: '
236 .implode(', ',array_keys(self::FREQUENCIES)) // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
237 );
238 }
239 $this->freq = $parts['FREQ'];
240 }
241 else { // string
242 $parts['FREQ'] = strtoupper($parts['FREQ']);
243 if (! array_key_exists($parts['FREQ'], self::FREQUENCIES)) {
244 throw new \InvalidArgumentException(
245 'The FREQ rule part must be one of the following: '
246 .implode(', ',array_keys(self::FREQUENCIES)) // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
247 );
248 }
249 $this->freq = self::FREQUENCIES[$parts['FREQ']];
250 }
251
252 // INTERVAL
253 if (filter_var($parts['INTERVAL'], FILTER_VALIDATE_INT, array('options' => array('min_range' => 1))) === false) {
254 throw new \InvalidArgumentException(
255 'The INTERVAL rule part must be a positive integer (> 0)'
256 );
257 }
258 $this->interval = (int) $parts['INTERVAL'];
259
260 // DTSTART
261 if (not_empty($parts['DTSTART'])) {
262 try {
263 $this->dtstart = self::parseDate($parts['DTSTART']);
264 } catch (\Exception $e) {
265 throw new \InvalidArgumentException(
266 'Failed to parse DTSTART ; it must be a valid date, timestamp or \DateTime object'
267 );
268 }
269 }
270 else {
271 $this->dtstart = new \DateTime(); // for PHP 7.1+ this contains microseconds which causes many problems
272 if (version_compare(PHP_VERSION, '7.1.0') >= 0) {
273 // remove microseconds
274 $this->dtstart->setTime(
275 $this->dtstart->format('H'),
276 $this->dtstart->format('i'),
277 $this->dtstart->format('s'),
278 0
279 );
280 }
281 }
282
283 // UNTIL (optional)
284 if (not_empty($parts['UNTIL'])) {
285 try {
286 $this->until = self::parseDate($parts['UNTIL']);
287 } catch (\Exception $e) {
288 throw new \InvalidArgumentException(
289 'Failed to parse UNTIL ; it must be a valid date, timestamp or \DateTime object'
290 );
291 }
292 }
293
294 // COUNT (optional)
295 if (not_empty($parts['COUNT'])) {
296 if (filter_var($parts['COUNT'], FILTER_VALIDATE_INT, array('options' => array('min_range' => 1))) === false) {
297 throw new \InvalidArgumentException('COUNT must be a positive integer (> 0)');
298 }
299 $this->count = (int) $parts['COUNT'];
300 }
301
302 if ($this->until && $this->count) {
303 throw new \InvalidArgumentException('The UNTIL or COUNT rule parts MUST NOT occur in the same rule');
304 }
305
306 // infer necessary BYXXX rules from DTSTART, if not provided
307 if (! (not_empty($parts['BYWEEKNO']) || not_empty($parts['BYYEARDAY']) || not_empty($parts['BYMONTHDAY']) || not_empty($parts['BYDAY']))) {
308 switch ($this->freq) {
309 case self::YEARLY:
310 if (! not_empty($parts['BYMONTH'])) {
311 $parts['BYMONTH'] = array((int) $this->dtstart->format('m'));
312 }
313 $parts['BYMONTHDAY'] = array((int) $this->dtstart->format('j'));
314 break;
315 case self::MONTHLY:
316 $parts['BYMONTHDAY'] = array((int) $this->dtstart->format('j'));
317 break;
318 case self::WEEKLY:
319 $parts['BYDAY'] = array(array_search($this->dtstart->format('N'), self::WEEKDAYS));
320 break;
321 }
322 }
323
324 // BYDAY (translated to byweekday for convenience)
325 if (not_empty($parts['BYDAY'])) {
326 if (! is_array($parts['BYDAY'])) {
327 $parts['BYDAY'] = explode(',',$parts['BYDAY']);
328 }
329 $this->byweekday = array();
330 $this->byweekday_nth = array();
331 foreach ($parts['BYDAY'] as $value) {
332 $value = trim(strtoupper($value));
333 $valid = preg_match('/^([+-]?[0-9]+)?([A-Z]{2})$/', $value, $matches);
334 if (! $valid || (not_empty($matches[1]) && ($matches[1] == 0 || $matches[1] > 53 || $matches[1] < -53)) || ! array_key_exists($matches[2], self::WEEKDAYS)) {
335 throw new \InvalidArgumentException('Invalid BYDAY value: ' . esc_html($value));
336 }
337
338 if ($matches[1]) {
339 $this->byweekday_nth[] = array(self::WEEKDAYS[$matches[2]], (int)$matches[1]);
340 }
341 else {
342 $this->byweekday[] = self::WEEKDAYS[$matches[2]];
343 }
344 }
345
346 if (! empty($this->byweekday_nth)) {
347 if (! ($this->freq === self::MONTHLY || $this->freq === self::YEARLY)) {
348 throw new \InvalidArgumentException('The BYDAY rule part MUST NOT be specified with a numeric value when the FREQ rule part is not set to MONTHLY or YEARLY.');
349 }
350 if ($this->freq === self::YEARLY && not_empty($parts['BYWEEKNO'])) {
351 throw new \InvalidArgumentException('The BYDAY rule part MUST NOT be specified with a numeric value with the FREQ rule part set to YEARLY when the BYWEEKNO rule part is specified.');
352 }
353 }
354 }
355
356 // The BYMONTHDAY rule part specifies a COMMA-separated list of days
357 // of the month. Valid values are 1 to 31 or -31 to -1. For
358 // example, -10 represents the tenth to the last day of the month.
359 // The BYMONTHDAY rule part MUST NOT be specified when the FREQ rule
360 // part is set to WEEKLY.
361 if (not_empty($parts['BYMONTHDAY'])) {
362 if ($this->freq === self::WEEKLY) {
363 throw new \InvalidArgumentException('The BYMONTHDAY rule part MUST NOT be specified when the FREQ rule part is set to WEEKLY.');
364 }
365
366 if (! is_array($parts['BYMONTHDAY'])) {
367 $parts['BYMONTHDAY'] = explode(',',$parts['BYMONTHDAY']);
368 }
369
370 $this->bymonthday = array();
371 $this->bymonthday_negative = array();
372 foreach ($parts['BYMONTHDAY'] as $value) {
373 if (!$value || filter_var($value, FILTER_VALIDATE_INT, array('options' => array('min_range' => -31, 'max_range' => 31))) === false) {
374 throw new \InvalidArgumentException('Invalid BYMONTHDAY value: ' . esc_html($value) . ' (valid values are 1 to 31 or -31 to -1)');
375 }
376 $value = (int) $value;
377 if ($value < 0) {
378 $this->bymonthday_negative[] = $value;
379 }
380 else {
381 $this->bymonthday[] = $value;
382 }
383 }
384 }
385
386 if (not_empty($parts['BYYEARDAY'])) {
387 if ($this->freq === self::DAILY || $this->freq === self::WEEKLY || $this->freq === self::MONTHLY) {
388 throw new \InvalidArgumentException('The BYYEARDAY rule part MUST NOT be specified when the FREQ rule part is set to DAILY, WEEKLY, or MONTHLY.');
389 }
390
391 if (! is_array($parts['BYYEARDAY'])) {
392 $parts['BYYEARDAY'] = explode(',',$parts['BYYEARDAY']);
393 }
394
395 $this->bysetpos = array();
396 foreach ($parts['BYYEARDAY'] as $value) {
397 if (! $value || filter_var($value, FILTER_VALIDATE_INT, array('options' => array('min_range' => -366, 'max_range' => 366))) === false) {
398 throw new \InvalidArgumentException('Invalid BYSETPOS value: '. esc_html($value) .' (valid values are 1 to 366 or -366 to -1)');
399 }
400
401 $this->byyearday[] = (int) $value;
402 }
403 }
404
405 // BYWEEKNO
406 if (not_empty($parts['BYWEEKNO'])) {
407 if ($this->freq !== self::YEARLY) {
408 throw new \InvalidArgumentException('The BYWEEKNO rule part MUST NOT be used when the FREQ rule part is set to anything other than YEARLY.');
409 }
410
411 if (! is_array($parts['BYWEEKNO'])) {
412 $parts['BYWEEKNO'] = explode(',',$parts['BYWEEKNO']);
413 }
414
415 $this->byweekno = array();
416 foreach ($parts['BYWEEKNO'] as $value) {
417 if (! $value || filter_var($value, FILTER_VALIDATE_INT, array('options' => array('min_range' => -53, 'max_range' => 53))) === false) {
418 throw new \InvalidArgumentException('Invalid BYWEEKNO value: '.esc_html($value).' (valid values are 1 to 53 or -53 to -1)');
419 }
420 $this->byweekno[] = (int) $value;
421 }
422 }
423
424 // The BYMONTH rule part specifies a COMMA-separated list of months
425 // of the year. Valid values are 1 to 12.
426 if (not_empty($parts['BYMONTH'])) {
427 if (! is_array($parts['BYMONTH'])) {
428 $parts['BYMONTH'] = explode(',',$parts['BYMONTH']);
429 }
430
431 $this->bymonth = array();
432 foreach ($parts['BYMONTH'] as $value) {
433 if (filter_var($value, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 12))) === false) {
434 throw new \InvalidArgumentException('Invalid BYMONTH value: '.esc_html($value));
435 }
436 $this->bymonth[] = (int) $value;
437 }
438 }
439
440 if (not_empty($parts['BYSETPOS'])) {
441 if (! (not_empty($parts['BYWEEKNO']) || not_empty($parts['BYYEARDAY'])
442 || not_empty($parts['BYMONTHDAY']) || not_empty($parts['BYDAY'])
443 || not_empty($parts['BYMONTH']) || not_empty($parts['BYHOUR'])
444 || not_empty($parts['BYMINUTE']) || not_empty($parts['BYSECOND']))) {
445 throw new \InvalidArgumentException('The BYSETPOS rule part MUST only be used in conjunction with another BYxxx rule part.');
446 }
447
448 if (! is_array($parts['BYSETPOS'])) {
449 $parts['BYSETPOS'] = explode(',',$parts['BYSETPOS']);
450 }
451
452 $this->bysetpos = array();
453 foreach ($parts['BYSETPOS'] as $value) {
454 if (! $value || filter_var($value, FILTER_VALIDATE_INT, array('options' => array('min_range' => -366, 'max_range' => 366))) === false) {
455 throw new \InvalidArgumentException('Invalid BYSETPOS value: '.esc_html($value).' (valid values are 1 to 366 or -366 to -1)');
456 }
457
458 $this->bysetpos[] = (int) $value;
459 }
460 }
461
462 if (not_empty($parts['BYHOUR'])) {
463 if (! is_array($parts['BYHOUR'])) {
464 $parts['BYHOUR'] = explode(',',$parts['BYHOUR']);
465 }
466
467 $this->byhour = array();
468 foreach ($parts['BYHOUR'] as $value) {
469 if (filter_var($value, FILTER_VALIDATE_INT, array('options' => array('min_range' => 0, 'max_range' => 23))) === false) {
470 throw new \InvalidArgumentException('Invalid BYHOUR value: '.esc_html($value));
471 }
472 $this->byhour[] = (int) $value;
473 }
474
475 sort($this->byhour);
476 }
477 elseif ($this->freq < self::HOURLY) {
478 $this->byhour = array((int) $this->dtstart->format('G'));
479 }
480
481 if (not_empty($parts['BYMINUTE'])) {
482 if (! is_array($parts['BYMINUTE'])) {
483 $parts['BYMINUTE'] = explode(',',$parts['BYMINUTE']);
484 }
485
486 $this->byminute = array();
487 foreach ($parts['BYMINUTE'] as $value) {
488 if (filter_var($value, FILTER_VALIDATE_INT, array('options' => array('min_range' => 0, 'max_range' => 59))) === false) {
489 throw new \InvalidArgumentException('Invalid BYMINUTE value: '.esc_html($value));
490 }
491 $this->byminute[] = (int) $value;
492 }
493 sort($this->byminute);
494 }
495 elseif ($this->freq < self::MINUTELY) {
496 $this->byminute = array((int) $this->dtstart->format('i'));
497 }
498
499 if (not_empty($parts['BYSECOND'])) {
500 if (! is_array($parts['BYSECOND'])) {
501 $parts['BYSECOND'] = explode(',',$parts['BYSECOND']);
502 }
503
504 $this->bysecond = array();
505 foreach ($parts['BYSECOND'] as $value) {
506 // yes, "60" is a valid value, in (very rare) cases on leap seconds
507 // December 31, 2005 23:59:60 UTC is a valid date...
508 // so is 2012-06-30T23:59:60UTC
509 if (filter_var($value, FILTER_VALIDATE_INT, array('options' => array('min_range' => 0, 'max_range' => 60))) === false) {
510 throw new \InvalidArgumentException('Invalid BYSECOND value: '.esc_html($value));
511 }
512 $this->bysecond[] = (int) $value;
513 }
514 sort($this->bysecond);
515 }
516 elseif ($this->freq < self::SECONDLY) {
517 $this->bysecond = array((int) $this->dtstart->format('s'));
518 }
519
520 if ($this->freq < self::HOURLY) {
521 // for frequencies DAILY, WEEKLY, MONTHLY AND YEARLY, we can build
522 // an array of every time of the day at which there should be an
523 // occurrence - default, if no BYHOUR/BYMINUTE/BYSECOND are provided
524 // is only one time, and it's the DTSTART time. This is a cached version
525 // if you will, since it'll never change at these frequencies
526 $this->timeset = array();
527 foreach ($this->byhour as $hour) {
528 foreach ($this->byminute as $minute) {
529 foreach ($this->bysecond as $second) {
530 $this->timeset[] = array($hour,$minute,$second);
531 }
532 }
533 }
534 }
535 }
536
537 /**
538 * Return the internal rule array, as it was passed to the constructor.
539 *
540 * @return array
541 */
542 public function getRule()
543 {
544 return $this->rule;
545 }
546
547 /**
548 * Magic string converter.
549 *
550 * @see RRule::rfcString()
551 * @return string a rfc string
552 */
553 public function __toString()
554 {
555 return $this->rfcString();
556 }
557
558 /**
559 * Format a rule according to RFC 5545
560 *
561 * @param bool $include_timezone Wether to generate a rule with timezone identifier on DTSTART (and UNTIL) or not.
562 * @return string
563 */
564 public function rfcString($include_timezone = true)
565 {
566 $str = '';
567 if ($this->rule['DTSTART']) {
568 if (! $include_timezone) {
569 $str = sprintf(
570 "DTSTART:%s\nRRULE:",
571 $this->dtstart->format('Ymd\THis')
572 );
573 }
574 else {
575 $dtstart = clone $this->dtstart;
576 $timezone_name = $dtstart->getTimeZone()->getName();
577 if (strpos($timezone_name,':') !== false) {
578 // handle unsupported timezones like "+02:00"
579 // we convert them to UTC to generate a valid string
580 // note: there is possibly other weird timezones out there that we should catch
581 $dtstart->setTimezone(new \DateTimeZone('UTC'));
582 $timezone_name = 'UTC';
583 }
584 if (in_array($timezone_name, array('UTC','GMT','Z'))) {
585 $str = sprintf(
586 "DTSTART:%s\nRRULE:",
587 $dtstart->format('Ymd\THis\Z')
588 );
589 }
590 else {
591 $str = sprintf(
592 "DTSTART;TZID=%s:%s\nRRULE:",
593 $timezone_name,
594 $dtstart->format('Ymd\THis')
595 );
596 }
597 }
598 }
599
600 $parts = array();
601 foreach ($this->rule as $key => $value) {
602 if ($key === 'DTSTART') {
603 continue;
604 }
605 if ($key === 'INTERVAL' && $value == 1) {
606 continue;
607 }
608 if ($key === 'WKST' && $value === 'MO') {
609 continue;
610 }
611 if ($key === 'UNTIL' && $value) {
612 if (! $include_timezone) {
613 $tmp = clone $this->until;
614 // put until on the same timezone as DTSTART
615 $tmp->setTimeZone($this->dtstart->getTimezone());
616 $parts[] = 'UNTIL='.$tmp->format('Ymd\THis');
617 }
618 else {
619 // according to the RFC, UNTIL must be in UTC
620 $tmp = clone $this->until;
621 $tmp->setTimezone(new \DateTimeZone('UTC'));
622 $parts[] = 'UNTIL='.$tmp->format('Ymd\THis\Z');
623 }
624 continue;
625 }
626 if ($key === 'FREQ' && $value && !array_key_exists($value, self::FREQUENCIES)) {
627 $frequency_key = array_search($value, self::FREQUENCIES);
628 if ($frequency_key !== false) {
629 $value = $frequency_key;
630 }
631 }
632 if ($value !== NULL) {
633 if (is_array($value)) {
634 $value = implode(',',$value);
635 }
636 $parts[] = strtoupper(str_replace(' ','',"$key=$value"));
637 }
638 }
639 $str .= implode(';',$parts);
640
641 return $str;
642 }
643
644 /**
645 * Take a RFC 5545 string and returns an array (to be given to the constructor)
646 *
647 * @param string $string The rule to be parsed
648 * @return array
649 *
650 * @throws \InvalidArgumentException on error
651 */
652 static public function parseRfcString($string)
653 {
654 trigger_error('parseRfcString() is deprecated - use new RRule(), RRule::createFromRfcString() or \RRule\RfcParser::parseRRule() if necessary',E_USER_DEPRECATED);
655 return RfcParser::parseRRule($string);
656 }
657
658 /**
659 * Take a RFC 5545 string and returns either a RRule or a RSet.
660 *
661 * @param string $string The RFC string
662 * @param bool $force_rset Force a RSet to be returned.
663 * @return RRule|RSet
664 *
665 * @throws \InvalidArgumentException on error
666 */
667 static public function createFromRfcString($string, $force_rset = false)
668 {
669 $class = '\FluentBooking\App\Services\Libs\RRule\RSet';
670
671 if (! $force_rset) {
672 // try to detect if we have a RRULE or a set
673 $upper_string = strtoupper($string);
674 $nb_rrule = substr_count($upper_string, 'RRULE');
675 if ($nb_rrule == 0) {
676 $class = '\FluentBooking\App\Services\Libs\RRule\RRule';
677 }
678 elseif ($nb_rrule > 1) {
679 $class = '\FluentBooking\App\Services\Libs\RRule\RSet';
680 }
681 else {
682 $class = '\FluentBooking\App\Services\Libs\RRule\RRule';
683 if (strpos($upper_string, 'EXDATE') !== false || strpos($upper_string, 'RDATE') !== false || strpos($upper_string, 'EXRULE') !== false) {
684 $class = '\FluentBooking\App\Services\Libs\RRule\RSet';
685 }
686 }
687 }
688
689 return new $class($string);
690 }
691
692 /**
693 * Clear the cache.
694 *
695 * It isn't recommended to use this method while iterating.
696 *
697 * @return $this
698 */
699 public function clearCache()
700 {
701 $this->total = null;
702 $this->cache = array();
703 return $this;
704 }
705
706 ///////////////////////////////////////////////////////////////////////////////
707 // RRule interface
708
709 /**
710 * Return true if the rrule has an end condition, false otherwise
711 *
712 * @return bool
713 */
714 public function isFinite()
715 {
716 return $this->count || $this->until;
717 }
718
719 /**
720 * Return true if the rrule has no end condition (infite)
721 *
722 * @return bool
723 */
724 public function isInfinite()
725 {
726 return ! $this->count && ! $this->until;
727 }
728
729 /**
730 * Return true if $date is an occurrence.
731 *
732 * This method will attempt to determine the result programmatically.
733 * However depending on the BYXXX rule parts that have been set, it might
734 * not always be possible. As a last resort, this method will loop
735 * through all occurrences until $date. This will incurr some performance
736 * penalty.
737 *
738 * @param mixed $date
739 * @return bool
740 */
741 public function occursAt($date)
742 {
743 $date = self::parseDate($date);
744 // convert timezone to dtstart timezone for comparison
745 $date->setTimezone($this->dtstart->getTimezone());
746
747 if (in_array($date, $this->cache)) {
748 // in the cache (whether cache is complete or not)
749 return true;
750 }
751 elseif ($this->total !== null) {
752 // cache complete and not in cache
753 return false;
754 }
755
756 // let's start with the obvious
757 if ($date < $this->dtstart || ($this->until && $date > $this->until)) {
758 return false;
759 }
760
761 // now the BYXXX rules (expect BYSETPOS)
762 if ($this->byhour && ! in_array($date->format('G'), $this->byhour)) {
763 return false;
764 }
765 if ($this->byminute && ! in_array((int) $date->format('i'), $this->byminute)) {
766 return false;
767 }
768 if ($this->bysecond && ! in_array((int) $date->format('s'), $this->bysecond)) {
769 return false;
770 }
771
772 // we need some more variables before we continue
773 list($year, $month, $day, $yearday, $weekday) = explode(' ',$date->format('Y n j z N'));
774 $masks = array();
775 $masks['weekday_of_1st_yearday'] = date_create($year.'-01-01 00:00:00')->format('N');
776 $masks['yearday_to_weekday'] = array_slice(self::WEEKDAY_MASK, $masks['weekday_of_1st_yearday']-1);
777 if (is_leap_year($year)) {
778 $masks['year_len'] = 366;
779 $masks['last_day_of_month'] = self::LAST_DAY_OF_MONTH_366;
780 }
781 else {
782 $masks['year_len'] = 365;
783 $masks['last_day_of_month'] = self::LAST_DAY_OF_MONTH;
784 }
785 $month_len = $masks['last_day_of_month'][$month] - $masks['last_day_of_month'][$month-1];
786
787 if ($this->bymonth && ! in_array($month, $this->bymonth)) {
788 return false;
789 }
790
791 if ($this->bymonthday || $this->bymonthday_negative) {
792 $monthday_negative = -1 * ($month_len - $day + 1);
793
794 if (! in_array($day, $this->bymonthday) && ! in_array($monthday_negative, $this->bymonthday_negative)) {
795 return false;
796 }
797 }
798
799 if ($this->byyearday) {
800 // caution here, yearday starts from 0 !
801 $yearday_negative = -1*($masks['year_len'] - $yearday);
802
803 if (! in_array($yearday+1, $this->byyearday) && ! in_array($yearday_negative, $this->byyearday)) {
804 return false;
805 }
806 }
807
808 if ($this->byweekday || $this->byweekday_nth) {
809 // we need to summon some magic here
810 $this->buildNthWeekdayMask($year, $month, $day, $masks);
811
812 if (! in_array($weekday, $this->byweekday) && ! isset($masks['yearday_is_nth_weekday'][$yearday])) {
813 return false;
814 }
815 }
816
817 if ($this->byweekno) {
818 // more magic
819 $this->buildWeeknoMask($year, $month, $day, $masks);
820 if (! isset($masks['yearday_is_in_weekno'][$yearday])) {
821 return false;
822 }
823 }
824
825 // so now we have exhausted all the BYXXX rules (exept bysetpos),
826 // we still need to consider frequency and interval
827 list($start_year, $start_month) = explode('-',$this->dtstart->format('Y-m'));
828 switch ($this->freq) {
829 case self::YEARLY:
830 if (($year - $start_year) % $this->interval !== 0) {
831 return false;
832 }
833 break;
834 case self::MONTHLY:
835 // we need to count the number of months elapsed
836 $diff = (12 - $start_month) + 12*($year - $start_year - 1) + $month;
837
838 if (($diff % $this->interval) !== 0) {
839 return false;
840 }
841 break;
842 case self::WEEKLY:
843 // count nb of days and divide by 7 to get number of weeks
844 // we add some days to align dtstart with wkst
845 $diff = $date->diff($this->dtstart);
846 $diff = (int) (($diff->days + pymod($this->dtstart->format('N') - $this->wkst,7)) / 7);
847 if ($diff % $this->interval !== 0) {
848 return false;
849 }
850 break;
851 case self::DAILY:
852 // count nb of days
853 $diff = $date->diff($this->dtstart);
854 if ($diff->days % $this->interval !== 0) {
855 return false;
856 }
857 break;
858 // XXX: I'm not sure the 3 formulas below take the DST into account...
859 case self::HOURLY:
860 $diff = $date->diff($this->dtstart);
861 $diff = $diff->h + $diff->days * 24;
862 if ($diff % $this->interval !== 0) {
863 return false;
864 }
865 break;
866 case self::MINUTELY:
867 $diff = $date->diff($this->dtstart);
868 $diff = $diff->i + $diff->h * 60 + $diff->days * 1440;
869 if ($diff % $this->interval !== 0) {
870 return false;
871 }
872 break;
873 case self::SECONDLY:
874 $diff = $date->diff($this->dtstart);
875 // XXX does not account for leap second (should it?)
876 $diff = $diff->s + $diff->i * 60 + $diff->h * 3600 + $diff->days * 86400;
877 if ($diff % $this->interval !== 0) {
878 return false;
879 }
880 break;
881 default:
882 throw new \Exception('Unimplemented frequency');
883 }
884
885 // now we are left with 2 rules BYSETPOS and COUNT
886 //
887 // - I think BYSETPOS *could* be determined without loooping by considering
888 // the current set, calculating all the occurrences of the current set
889 // and determining the position of $date in the result set.
890 // However I'm not convinced it's worth it.
891 //
892 // - I don't see any way to determine COUNT programmatically, because occurrences
893 // might sometimes be dropped (e.g. a 29 Feb on a normal year, or during
894 // the switch to DST) and not counted in the final set
895
896 if (! $this->count && ! $this->bysetpos) {
897 return true;
898 }
899
900 // so... as a fallback we have to loop
901 foreach ($this as $occurrence) {
902 if ($occurrence == $date) {
903 return true; // lucky you!
904 }
905 if ($occurrence > $date) {
906 break;
907 }
908 }
909
910 // we ended the loop without finding
911 return false;
912 }
913
914 ///////////////////////////////////////////////////////////////////////////////
915 // ArrayAccess interface
916
917 /**
918 * @internal
919 * @return bool
920 */
921 #[\ReturnTypeWillChange]
922 public function offsetExists($offset)
923 {
924 return is_numeric($offset) && $offset >= 0 && ! is_float($offset) && $offset < count($this);
925 }
926
927 /**
928 * @internal
929 * @return mixed
930 */
931 #[\ReturnTypeWillChange]
932 public function offsetGet($offset)
933 {
934 if (! is_numeric($offset) || $offset < 0 || is_float($offset)) {
935 throw new \InvalidArgumentException('Illegal offset type: '. esc_html(gettype($offset)));
936 }
937
938 if (isset($this->cache[$offset])) {
939 // found in cache
940 return clone $this->cache[$offset];
941 }
942 elseif ($this->total !== null) {
943 // cache complete and not found in cache
944 return null;
945 }
946
947 // not in cache and cache not complete, we have to loop to find it
948 $i = 0;
949 foreach ($this as $occurrence) {
950 if ($i == $offset) {
951 return $occurrence;
952 }
953 $i++;
954 if ($i > $offset) {
955 break;
956 }
957 }
958 return null;
959 }
960
961 /**
962 * @internal
963 * @return void
964 */
965 #[\ReturnTypeWillChange]
966 public function offsetSet($offset, $value)
967 {
968 throw new \LogicException('Setting a Date in a RRule is not supported');
969 }
970
971 /**
972 * @internal
973 * @return void
974 */
975 #[\ReturnTypeWillChange]
976 public function offsetUnset($offset)
977 {
978 throw new \LogicException('Unsetting a Date in a RRule is not supported');
979 }
980
981 ///////////////////////////////////////////////////////////////////////////////
982 // Countable interface
983
984 /**
985 * Returns the number of occurrences in this rule. It will have go
986 * through the whole recurrence, if this hasn't been done before, which
987 * introduces a performance penality.
988 *
989 * @return int
990 */
991 #[\ReturnTypeWillChange]
992 public function count()
993 {
994 if ($this->isInfinite()) {
995 throw new \LogicException('Cannot count an infinite recurrence rule.');
996 }
997
998 if ($this->total === null) {
999 foreach ($this as $occurrence) {}
1000 }
1001
1002 return $this->total;
1003 }
1004
1005 ///////////////////////////////////////////////////////////////////////////////
1006 // Internal methods
1007 // where all the magic happens
1008
1009 /**
1010 * Return an array of days of the year (numbered from 0 to 365)
1011 * of the current timeframe (year, month, week, day) containing the current date
1012 *
1013 * @param int $year
1014 * @param int $month
1015 * @param int $day
1016 * @param array $masks
1017 * @return array
1018 */
1019 protected function getDaySet($year, $month, $day, array $masks)
1020 {
1021 switch ($this->freq) {
1022 case self::YEARLY:
1023 return range(0,$masks['year_len']-1);
1024
1025 case self::MONTHLY:
1026 $start = $masks['last_day_of_month'][$month-1];
1027 $stop = $masks['last_day_of_month'][$month];
1028 return range($start, $stop - 1);
1029
1030 case self::WEEKLY:
1031 // on first iteration, the first week will not be complete
1032 // we don't backtrack to the first day of the week, to avoid
1033 // crossing year boundary in reverse (i.e. if the week started
1034 // during the previous year), because that would generate
1035 // negative indexes (which would not work with the masks)
1036 $set = array();
1037 $i = (int) date_create($year.'-'.$month.'-'.$day.' 00:00:00')->format('z');
1038 $start = $i;
1039 for ($j = 0; $j < 7; $j++) {
1040 $set[] = $i;
1041 $i += 1;
1042 if ($masks['yearday_to_weekday'][$i] == $this->wkst) {
1043 break;
1044 }
1045 }
1046 return $set;
1047
1048 case self::DAILY:
1049 case self::HOURLY:
1050 case self::MINUTELY:
1051 case self::SECONDLY:
1052 $i = (int) date_create($year.'-'.$month.'-'.$day.' 00:00:00')->format('z');
1053 return array($i);
1054 }
1055 }
1056
1057 /**
1058 * Calculate the yeardays corresponding to each Nth weekday
1059 * (in BYDAY rule part).
1060 *
1061 * For example, in Jan 1998, in a MONTHLY interval, "1SU,-1SU" (first Sunday
1062 * and last Sunday) would be transformed into [3=>true,24=>true] because
1063 * the first Sunday of Jan 1998 is yearday 3 (counting from 0) and the
1064 * last Sunday of Jan 1998 is yearday 24 (counting from 0).
1065 *
1066 * @param int $year (not used)
1067 * @param int $month
1068 * @param int $day (not used)
1069 * @param array $masks
1070 *
1071 * @return null (modifies $masks parameter)
1072 */
1073 protected function buildNthWeekdayMask($year, $month, $day, array & $masks)
1074 {
1075 $masks['yearday_is_nth_weekday'] = array();
1076
1077 if ($this->byweekday_nth) {
1078 $ranges = array();
1079 if ($this->freq == self::YEARLY) {
1080 if ($this->bymonth) {
1081 foreach ($this->bymonth as $bymonth) {
1082 $ranges[] = array(
1083 $masks['last_day_of_month'][$bymonth - 1],
1084 $masks['last_day_of_month'][$bymonth] - 1
1085 );
1086 }
1087 }
1088 else {
1089 $ranges = array(array(0, $masks['year_len'] - 1));
1090 }
1091 }
1092 elseif ($this->freq == self::MONTHLY) {
1093 $ranges[] = array(
1094 $masks['last_day_of_month'][$month - 1],
1095 $masks['last_day_of_month'][$month] - 1
1096 );
1097 }
1098
1099 if ($ranges) {
1100 // Weekly frequency won't get here, so we may not
1101 // care about cross-year weekly periods.
1102 foreach ($ranges as $tmp) {
1103 list($first, $last) = $tmp;
1104 foreach ($this->byweekday_nth as $tmp) {
1105 list($weekday, $nth) = $tmp;
1106 if ($nth < 0) {
1107 $i = $last + ($nth + 1) * 7;
1108 $i = $i - pymod($masks['yearday_to_weekday'][$i] - $weekday, 7);
1109 }
1110 else {
1111 $i = $first + ($nth - 1) * 7;
1112 $i = $i + (7 - $masks['yearday_to_weekday'][$i] + $weekday) % 7;
1113 }
1114
1115 if ($i >= $first && $i <= $last) {
1116 $masks['yearday_is_nth_weekday'][$i] = true;
1117 }
1118 }
1119 }
1120 }
1121 }
1122 }
1123
1124 /**
1125 * Calculate the yeardays corresponding to the week number
1126 * (in the WEEKNO rule part).
1127 *
1128 * Because weeks can cross year boundaries (that is, week #1 can start the
1129 * previous year, and week 52/53 can continue till the next year), the
1130 * algorithm is quite long.
1131 *
1132 * @param int $year
1133 * @param int $month (not used)
1134 * @param int $day (not used)
1135 * @param array $masks
1136 *
1137 * @return null (modifies $masks)
1138 */
1139 protected function buildWeeknoMask($year, $month, $day, array & $masks)
1140 {
1141 $masks['yearday_is_in_weekno'] = array();
1142
1143 // calculate the index of the first wkst day of the year
1144 // 0 means the first day of the year is the wkst day (e.g. wkst is Monday and Jan 1st is a Monday)
1145 // n means there is n days before the first wkst day of the year.
1146 // if n >= 4, this is the first day of the year (even though it started the year before)
1147 $first_wkst = (7 - $masks['weekday_of_1st_yearday'] + $this->wkst) % 7;
1148 if($first_wkst >= 4) {
1149 $first_wkst_offset = 0;
1150 // Number of days in the year, plus the days we got from last year.
1151 $nb_days = $masks['year_len'] + $masks['weekday_of_1st_yearday'] - $this->wkst;
1152 // $nb_days = $masks['year_len'] + pymod($masks['weekday_of_1st_yearday'] - $this->wkst,7);
1153 }
1154 else {
1155 $first_wkst_offset = $first_wkst;
1156 // Number of days in the year, minus the days we left in last year.
1157 $nb_days = $masks['year_len'] - $first_wkst;
1158 }
1159 $nb_weeks = (int) ($nb_days / 7) + (int) (($nb_days % 7) / 4);
1160
1161 // alright now we now when the first week starts
1162 // and the number of weeks of the year
1163 // so we can generate a map of every yearday that are in the weeks
1164 // specified in byweekno
1165 foreach ($this->byweekno as $n) {
1166 if ($n < 0) {
1167 $n = $n + $nb_weeks + 1;
1168 }
1169 if ($n <= 0 || $n > $nb_weeks) {
1170 continue;
1171 }
1172 if ($n > 1) {
1173 $i = $first_wkst_offset + ($n - 1) * 7;
1174 if ($first_wkst_offset != $first_wkst) {
1175 // if week #1 started the previous year
1176 // realign the start of the week
1177 $i = $i - (7 - $first_wkst);
1178 }
1179 }
1180 else {
1181 $i = $first_wkst_offset;
1182 }
1183
1184 // now add 7 days into the resultset, stopping either at 7 or
1185 // if we reach wkst before (in the case of short first week of year)
1186 for ($j = 0; $j < 7; $j++) {
1187 $masks['yearday_is_in_weekno'][$i] = true;
1188 $i = $i + 1;
1189 if ($masks['yearday_to_weekday'][$i] == $this->wkst) {
1190 break;
1191 }
1192 }
1193 }
1194
1195 // if we asked for week #1, it's possible that the week #1 of next year
1196 // already started this year. Therefore we need to return also the matching
1197 // days of next year.
1198 if (in_array(1, $this->byweekno)) {
1199 // Check week number 1 of next year as well
1200 // TODO: Check -numweeks for next year.
1201 $i = $first_wkst_offset + $nb_weeks * 7;
1202 if ($first_wkst_offset != $first_wkst) {
1203 $i = $i - (7 - $first_wkst);
1204 }
1205 if ($i < $masks['year_len']) {
1206 // If week starts in next year, we don't care about it.
1207 for ($j = 0; $j < 7; $j++) {
1208 $masks['yearday_is_in_weekno'][$i] = true;
1209 $i += 1;
1210 if ($masks['yearday_to_weekday'][$i] == $this->wkst) {
1211 break;
1212 }
1213 }
1214 }
1215 }
1216
1217 if ($first_wkst_offset) {
1218 // Check last week number of last year as well.
1219 // If first_wkst_offset is 0, either the year started on week start,
1220 // or week number 1 got days from last year, so there are no
1221 // days from last year's last week number in this year.
1222 if (! in_array(-1, $this->byweekno)) {
1223 $weekday_of_1st_yearday = date_create(($year-1).'-01-01 00:00:00')->format('N');
1224 $first_wkst_offset_last_year = (7 - $weekday_of_1st_yearday + $this->wkst) % 7;
1225 $last_year_len = 365 + is_leap_year($year - 1);
1226 if ($first_wkst_offset_last_year >= 4) {
1227 $first_wkst_offset_last_year = 0;
1228 $nb_weeks_last_year = 52 + (int) ((($last_year_len + ($weekday_of_1st_yearday - $this->wkst) % 7) % 7) / 4);
1229 }
1230 else {
1231 $nb_weeks_last_year = 52 + (int) ((($masks['year_len'] - $first_wkst_offset) % 7) /4);
1232 }
1233 }
1234 else {
1235 $nb_weeks_last_year = -1;
1236 }
1237
1238 if (in_array($nb_weeks_last_year, $this->byweekno)) {
1239 for ($i = 0; $i < $first_wkst_offset; $i++) {
1240 $masks['yearday_is_in_weekno'][$i] = true;
1241 }
1242 }
1243 }
1244 }
1245
1246
1247 /**
1248 * Build an array of every time of the day that matches the BYXXX time
1249 * criteria.
1250 *
1251 * It will only process $this->frequency at one time. So:
1252 * - for HOURLY frequencies it builds the minutes and second of the given hour
1253 * - for MINUTELY frequencies it builds the seconds of the given minute
1254 * - for SECONDLY frequencies, it returns an array with one element
1255 *
1256 * This method is called everytime an increment of at least one hour is made.
1257 *
1258 * @param int $hour
1259 * @param int $minute
1260 * @param int $second
1261 *
1262 * @return array
1263 */
1264 protected function getTimeSet($hour, $minute, $second)
1265 {
1266 switch ($this->freq) {
1267 case self::HOURLY:
1268 $set = array();
1269 foreach ($this->byminute as $minute) {
1270 foreach ($this->bysecond as $second) {
1271 // should we use another type?
1272 $set[] = array($hour, $minute, $second);
1273 }
1274 }
1275 // sort ?
1276 return $set;
1277 case self::MINUTELY:
1278 $set = array();
1279 foreach ($this->bysecond as $second) {
1280 // should we use another type?
1281 $set[] = array($hour, $minute, $second);
1282 }
1283 // sort ?
1284 return $set;
1285 case self::SECONDLY:
1286 return array(array($hour, $minute, $second));
1287 default:
1288 throw new \LogicException('getTimeSet called with an invalid frequency');
1289 }
1290 }
1291
1292 /**
1293 * This is the main method, where all of the magic happens.
1294 *
1295 * The main idea is: a brute force loop testing all the dates, made fast by
1296 * not relying on date() functions
1297 *
1298 * There is one big loop that examines every interval of the given frequency
1299 * (so every day, every week, every month or every year), constructs an
1300 * array of all the yeardays of the interval (for daily frequencies, the array
1301 * only has one element, for weekly 7, and so on), and then filters out any
1302 * day that do no match BYXXX parts.
1303 *
1304 * The algorithm does not try to be "smart" in calculating the increment of
1305 * the loop. That is, for a rule like "every day in January for 10 years"
1306 * the algorithm will loop through every day of the year, each year, generating
1307 * some 3650 iterations (+ some to account for the leap years).
1308 * This is a bit counter-intuitive, as it is obvious that the loop could skip
1309 * all the days in February till December since they are never going to match.
1310 *
1311 * Fortunately, this approach is still super fast because it doesn't rely
1312 * on date() or DateTime functions, and instead does all the date operations
1313 * manually, either arithmetically or using arrays as converters.
1314 *
1315 * Another quirk of this approach is that because the granularity is by day,
1316 * higher frequencies (hourly, minutely and secondly) have to have
1317 * their own special loops within the main loop, making the whole thing quite
1318 * convoluted.
1319 * Moreover, at such frequencies, the brute-force approach starts to really
1320 * suck. For example, a rule like
1321 * "Every minute, every Jan 1st between 10:00 and 10:59, for 10 years"
1322 * requires a tremendous amount of useless iterations to jump from Jan 1st 10:59
1323 * at year 1 to Jan 1st 10.00 at year 2.
1324 *
1325 * In order to make a "smart jump", we would have to have a way to determine
1326 * the gap between the next occurrence arithmetically. I think that would require
1327 * to analyze each "BYXXX" rule part that "Limit" the set (see the RFC page 43)
1328 * at the given frequency. For example, a YEARLY frequency doesn't need "smart
1329 * jump" at all; MONTHLY and WEEKLY frequencies only need to check BYMONTH;
1330 * DAILY frequency needs to check BYMONTH, BYMONTHDAY and BYDAY, and so on.
1331 * The check probably has to be done in reverse order, e.g. for DAILY frequencies
1332 * attempt to jump to the next weekday (BYDAY) or next monthday (BYMONTHDAY)
1333 * (I don't know yet which one first), and then if that results in a change of
1334 * month, attempt to jump to the next BYMONTH, and so on.
1335 *
1336 * @return \DateTime|null
1337 */
1338 #[\ReturnTypeWillChange]
1339 public function getIterator()
1340 {
1341 $total = 0;
1342 $occurrence = null;
1343 $dtstart = null;
1344 $dayset = null;
1345
1346 // go through the cache first
1347 foreach ($this->cache as $occurrence) {
1348 yield clone $occurrence; // since DateTime is not immutable, avoid any problem
1349
1350 $total += 1;
1351 }
1352
1353 // if the cache as been used up completely and we now there is nothing else,
1354 // we can stop the generator
1355 if ($total === $this->total) {
1356 return; // end generator
1357 }
1358
1359 if ($occurrence) {
1360 $dtstart = clone $occurrence; // since DateTime is not immutable, clone to avoid any problem
1361 // so we skip the last occurrence of the cache
1362 if ($this->freq === self::SECONDLY) {
1363 $dtstart = $dtstart->modify('+'.$this->interval.'second');
1364 }
1365 else {
1366 $dtstart = $dtstart->modify('+1second');
1367 }
1368 }
1369
1370 if ($dtstart === null) {
1371 $dtstart = clone $this->dtstart;
1372 }
1373
1374 if ($this->freq === self::WEEKLY) {
1375 // we align the start date to the WKST, so we can then
1376 // simply loop by adding +7 days. The Python lib does some
1377 // calculation magic at the end of the loop (when incrementing)
1378 // to realign on first pass.
1379 $tmp = clone $dtstart;
1380 $tmp = $tmp->modify('-'.pymod($dtstart->format('N') - $this->wkst,7).'days');
1381 list($year,$month,$day,$hour,$minute,$second) = explode(' ',$tmp->format('Y n j G i s'));
1382 unset($tmp);
1383 }
1384 else {
1385 list($year,$month,$day,$hour,$minute,$second) = explode(' ',$dtstart->format('Y n j G i s'));
1386 }
1387 // remove leading zeros
1388 $minute = (int) $minute;
1389 $second = (int) $second;
1390
1391 // we initialize the timeset
1392 if ($this->freq < self::HOURLY) {
1393 // daily, weekly, monthly or yearly
1394 // we don't need to calculate a new timeset
1395 $timeset = $this->timeset;
1396 }
1397 else {
1398 // initialize empty if it's not going to occur on the first iteration
1399 if (
1400 ($this->freq >= self::HOURLY && $this->byhour && ! in_array($hour, $this->byhour))
1401 || ($this->freq >= self::MINUTELY && $this->byminute && ! in_array($minute, $this->byminute))
1402 || ($this->freq >= self::SECONDLY && $this->bysecond && ! in_array($second, $this->bysecond))
1403 ) {
1404 $timeset = array();
1405 }
1406 else {
1407 $timeset = $this->getTimeSet($hour, $minute, $second);
1408 }
1409 }
1410
1411 $max_cycles = self::MAX_CYCLES[$this->freq <= self::DAILY ? $this->freq : self::DAILY];
1412 for ($i = 0; $i < $max_cycles; $i++) {
1413 // 1. get an array of all days in the next interval (day, month, week, etc.)
1414 // we filter out from this array all days that do not match the BYXXX conditions
1415 // to speed things up, we use days of the year (day numbers) instead of date
1416 if ($dayset === null) {
1417 // rebuild the various masks and converters
1418 // these arrays will allow fast date operations
1419 // without relying on date() methods
1420 if (empty($masks) || $masks['year'] != $year || $masks['month'] != $month) {
1421 $masks = array('year' => '','month'=>'');
1422 // only if year has changed
1423 if ($masks['year'] != $year) {
1424 $masks['leap_year'] = is_leap_year($year);
1425 $masks['year_len'] = 365 + (int) $masks['leap_year'];
1426 $masks['weekday_of_1st_yearday'] = date_create($year."-01-01 00:00:00")->format('N');
1427 $masks['yearday_to_weekday'] = array_slice(self::WEEKDAY_MASK, $masks['weekday_of_1st_yearday']-1);
1428 if ($masks['leap_year']) {
1429 $masks['yearday_to_month'] = self::MONTH_MASK_366;
1430 $masks['yearday_to_monthday'] = self::MONTHDAY_MASK_366;
1431 $masks['yearday_to_monthday_negative'] = self::NEGATIVE_MONTHDAY_MASK_366;
1432 $masks['last_day_of_month'] = self::LAST_DAY_OF_MONTH_366;
1433 }
1434 else {
1435 $masks['yearday_to_month'] = self::MONTH_MASK;
1436 $masks['yearday_to_monthday'] = self::MONTHDAY_MASK;
1437 $masks['yearday_to_monthday_negative'] = self::NEGATIVE_MONTHDAY_MASK;
1438 $masks['last_day_of_month'] = self::LAST_DAY_OF_MONTH;
1439 }
1440 if ($this->byweekno) {
1441 $this->buildWeeknoMask($year, $month, $day, $masks);
1442 }
1443 }
1444 // everytime month or year changes
1445 if ($this->byweekday_nth) {
1446 $this->buildNthWeekdayMask($year, $month, $day, $masks);
1447 }
1448 $masks['year'] = $year;
1449 $masks['month'] = $month;
1450 }
1451
1452 // calculate the current set
1453 $dayset = $this->getDaySet($year, $month, $day, $masks);
1454
1455 $filtered_set = array();
1456 // filter out the days based on the BYXXX rules
1457 foreach ($dayset as $yearday) {
1458 if ($this->bymonth && ! in_array($masks['yearday_to_month'][$yearday], $this->bymonth)) {
1459 continue;
1460 }
1461
1462 if ($this->byweekno && ! isset($masks['yearday_is_in_weekno'][$yearday])) {
1463 continue;
1464 }
1465
1466 if ($this->byyearday) {
1467 if (! in_array($yearday + 1, $this->byyearday) && ! in_array(- $masks['year_len'] + $yearday,$this->byyearday)) {
1468 continue;
1469 }
1470 }
1471
1472 if (($this->bymonthday || $this->bymonthday_negative)
1473 && ! in_array($masks['yearday_to_monthday'][$yearday], $this->bymonthday)
1474 && ! in_array($masks['yearday_to_monthday_negative'][$yearday], $this->bymonthday_negative)) {
1475 continue;
1476 }
1477
1478 if (($this->byweekday || $this->byweekday_nth)
1479 && ! in_array($masks['yearday_to_weekday'][$yearday], $this->byweekday)
1480 && ! isset($masks['yearday_is_nth_weekday'][$yearday])) {
1481 continue;
1482 }
1483
1484 $filtered_set[] = $yearday;
1485 }
1486
1487 $dayset = $filtered_set;
1488
1489 // if BYSETPOS is set, we need to expand the timeset to filter by pos
1490 // so we make a special loop to return while generating
1491 // TODO this is not needed with a generator anymore
1492 // we can yield directly within the loop
1493 if ($this->bysetpos && $timeset) {
1494 $filtered_set = array();
1495 foreach ($this->bysetpos as $pos) {
1496 $n = count($timeset);
1497 if ($pos < 0) {
1498 $pos = $n * count($dayset) + $pos;
1499 }
1500 else {
1501 $pos = $pos - 1;
1502 }
1503
1504 $div = (int) ($pos / $n); // daypos
1505 $mod = $pos % $n; // timepos
1506 if (isset($dayset[$div]) && isset($timeset[$mod])) {
1507 $yearday = $dayset[$div];
1508 $time = $timeset[$mod];
1509 // used as array key to ensure uniqueness
1510 $tmp = $year.':'.$yearday.':'.$time[0].':'.$time[1].':'.$time[2];
1511 if (! isset($filtered_set[$tmp])) {
1512 $occurrence = \DateTime::createFromFormat(
1513 'Y z H:i:s',
1514 "$year $yearday 00:00:00",
1515 $this->dtstart->getTimezone()
1516 );
1517 $occurrence->setTime($time[0], $time[1], $time[2]);
1518 $filtered_set[$tmp] = $occurrence;
1519 }
1520 }
1521 }
1522 sort($filtered_set);
1523 $dayset = $filtered_set;
1524 }
1525 }
1526
1527 // 2. loop, generate a valid date, and yield the result
1528 // at the same time, we check the end condition and return null if
1529 // we need to stop
1530 if ($this->bysetpos && $timeset) {
1531 // while ( ($occurrence = current($dayset)) !== false ) {
1532 foreach ($dayset as $occurrence) {
1533 // consider end conditions
1534 if ($this->until && $occurrence > $this->until) {
1535 $this->total = $total; // save total for count() cache
1536 return;
1537 }
1538
1539 // next($dayset);
1540 if ($occurrence >= $dtstart) { // ignore occurrences before DTSTART
1541 if ($this->count && $total >= $this->count) {
1542 $this->total = $total;
1543 return;
1544 }
1545 $total += 1;
1546 $this->cache[] = clone $occurrence;
1547 yield clone $occurrence; // yield
1548 $i = 0; // reset the max cycles counter, since we yieled a result
1549 }
1550 }
1551 }
1552 else {
1553 // normal loop, without BYSETPOS
1554 foreach ($dayset as $yearday) {
1555 $occurrence = \DateTime::createFromFormat(
1556 'Y z H:i:s',
1557 "$year $yearday 00:00:00",
1558 $this->dtstart->getTimezone()
1559 );
1560
1561 // while ( ($time = current($timeset)) !== false ) {
1562 foreach ($timeset as $time) {
1563 $occurrence->setTime($time[0], $time[1], $time[2]);
1564 // consider end conditions
1565 if ($this->until && $occurrence > $this->until) {
1566 $this->total = $total; // save total for count() cache
1567 return;
1568 }
1569
1570 if ($occurrence >= $dtstart) { // ignore occurrences before DTSTART
1571 if ($this->count && $total >= $this->count) {
1572 $this->total = $total;
1573 return;
1574 }
1575 $total += 1;
1576 $this->cache[] = clone $occurrence;
1577 yield clone $occurrence; // yield
1578 $i = 0; // reset the max cycles counter, since we yieled a result
1579 }
1580 }
1581 }
1582 }
1583
1584 // 3. we reset the loop to the next interval
1585 $days_increment = 0;
1586 switch ($this->freq) {
1587 case self::YEARLY:
1588 // we do not care about $month or $day not existing,
1589 // they are not used in yearly frequency
1590 $year = $year + $this->interval;
1591 break;
1592 case self::MONTHLY:
1593 // we do not care about the day of the month not existing
1594 // it is not used in monthly frequency
1595 $month = $month + $this->interval;
1596 if ($month > 12) {
1597 $div = (int) ($month / 12);
1598 $mod = $month % 12;
1599 $month = $mod;
1600 $year = $year + $div;
1601 if ($month == 0) {
1602 $month = 12;
1603 $year = $year - 1;
1604 }
1605 }
1606 break;
1607 case self::WEEKLY:
1608 $days_increment = $this->interval*7;
1609 break;
1610 case self::DAILY:
1611 $days_increment = $this->interval;
1612 break;
1613
1614 // For the time frequencies, things are a little bit different.
1615 // We could just add "$this->interval" hours, minutes or seconds
1616 // to the current time, and go through the main loop again,
1617 // but since the frequencies are so high and needs to much iteration
1618 // it's actually a bit faster to have custom loops and only
1619 // call the DateTime method at the very end.
1620
1621 case self::HOURLY:
1622 if (empty($dayset)) {
1623 // an empty set means that this day has been filtered out
1624 // by one of the BYXXX rule. So there is no need to
1625 // examine it any further, we know nothing is going to
1626 // occur anyway.
1627 // so we jump to one iteration right before next day
1628 $hour += ((int) ((23 - $hour) / $this->interval)) * $this->interval;
1629 }
1630
1631 $found = false;
1632 for ($j = 0; $j < self::MAX_CYCLES[self::HOURLY]; $j++) {
1633 $hour += $this->interval;
1634 $div = (int) ($hour / 24);
1635 $mod = $hour % 24;
1636 if ($div) {
1637 $hour = $mod;
1638 $days_increment += $div;
1639 }
1640 if (! $this->byhour || in_array($hour, $this->byhour)) {
1641 $found = true;
1642 break;
1643 }
1644 }
1645
1646 if (! $found) {
1647 $this->total = $total; // save total for count cache
1648 return; // stop the iterator
1649 }
1650
1651 $timeset = $this->getTimeSet($hour, $minute, $second);
1652 break;
1653 case self::MINUTELY:
1654 if (empty($dayset)) {
1655 $minute += ((int) ((1439 - ($hour*60+$minute)) / $this->interval)) * $this->interval;
1656 }
1657
1658 $found = false;
1659 for ($j = 0; $j < self::MAX_CYCLES[self::MINUTELY]; $j++) {
1660 $minute += $this->interval;
1661 $div = (int) ($minute / 60);
1662 $mod = $minute % 60;
1663 if ($div) {
1664 $minute = $mod;
1665 $hour += $div;
1666 $div = (int) ($hour / 24);
1667 $mod = $hour % 24;
1668 if ($div) {
1669 $hour = $mod;
1670 $days_increment += $div;
1671 }
1672 }
1673 if ((! $this->byhour || in_array($hour, $this->byhour)) &&
1674 (! $this->byminute || in_array($minute, $this->byminute))) {
1675 $found = true;
1676 break;
1677 }
1678 }
1679
1680 if (! $found) {
1681 $this->total = $total; // save total for count cache
1682 return; // stop the iterator
1683 }
1684
1685 $timeset = $this->getTimeSet($hour, $minute, $second);
1686 break;
1687 case self::SECONDLY:
1688 if (empty($dayset)) {
1689 $second += ((int) ((86399 - ($hour*3600 + $minute*60 + $second)) / $this->interval)) * $this->interval;
1690 }
1691
1692 $found = false;
1693 for ($j = 0; $j < self::MAX_CYCLES[self::SECONDLY]; $j++) {
1694 $second += $this->interval;
1695 $div = (int) ($second / 60);
1696 $mod = $second % 60;
1697 if ($div) {
1698 $second = $mod;
1699 $minute += $div;
1700 $div = (int) ($minute / 60);
1701 $mod = $minute % 60;
1702 if ($div) {
1703 $minute = $mod;
1704 $hour += $div;
1705 $div = (int) ($hour / 24);
1706 $mod = $hour % 24;
1707 if ($div) {
1708 $hour = $mod;
1709 $days_increment += $div;
1710 }
1711 }
1712 }
1713 if ((! $this->byhour || in_array($hour, $this->byhour))
1714 && (! $this->byminute || in_array($minute, $this->byminute))
1715 && (! $this->bysecond || in_array($second, $this->bysecond))) {
1716 $found = true;
1717 break;
1718 }
1719 }
1720
1721 if (! $found) {
1722 $this->total = $total; // save total for count cache
1723 return; // stop the iterator
1724 }
1725
1726 $timeset = $this->getTimeSet($hour, $minute, $second);
1727 break;
1728 }
1729 // here we take a little shortcut from the Python version, by using DateTime
1730 if ($days_increment) {
1731 list($year,$month,$day) = explode('-',date_create("$year-$month-$day")->modify("+ $days_increment days")->format('Y-n-j'));
1732 }
1733 $dayset = null; // reset the loop
1734 }
1735
1736 $this->total = $total; // save total for count cache
1737 return; // stop the iterator
1738 }
1739
1740 ///////////////////////////////////////////////////////////////////////////////
1741 // constants
1742 // Every mask is 7 days longer to handle cross-year weekly periods.
1743
1744 const MONTH_MASK = [
1745 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,
1746 2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,
1747 3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,
1748 4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,
1749 5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,
1750 6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,
1751 7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,
1752 8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,
1753 9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,
1754 10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,
1755 11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,
1756 12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,
1757 1,1,1,1,1,1,1
1758 ];
1759
1760 const MONTH_MASK_366 = [
1761 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,
1762 2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,
1763 3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,
1764 4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,
1765 5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,
1766 6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,
1767 7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,
1768 8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,
1769 9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,
1770 10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,
1771 11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,11,
1772 12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,
1773 1,1,1,1,1,1,1
1774 ];
1775
1776 const MONTHDAY_MASK = [
1777 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,
1778 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,
1779 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,
1780 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,
1781 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,
1782 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,
1783 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,
1784 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,
1785 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,
1786 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,
1787 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,
1788 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,
1789 1,2,3,4,5,6,7
1790 ];
1791
1792 const MONTHDAY_MASK_366 = [
1793 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,
1794 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,
1795 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,
1796 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,
1797 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,
1798 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,
1799 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,
1800 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,
1801 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,
1802 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,
1803 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,
1804 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,
1805 1,2,3,4,5,6,7
1806 ];
1807
1808 const NEGATIVE_MONTHDAY_MASK = [
1809 -31,-30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1,
1810 -28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1,
1811 -31,-30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1,
1812 -30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1,
1813 -31,-30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1,
1814 -30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1,
1815 -31,-30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1,
1816 -31,-30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1,
1817 -30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1,
1818 -31,-30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1,
1819 -30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1,
1820 -31,-30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1,
1821 -31,-30,-29,-28,-27,-26,-25
1822 ];
1823
1824 const NEGATIVE_MONTHDAY_MASK_366 = [
1825 -31,-30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1,
1826 -29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1,
1827 -31,-30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1,
1828 -30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1,
1829 -31,-30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1,
1830 -30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1,
1831 -31,-30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1,
1832 -31,-30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1,
1833 -30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1,
1834 -31,-30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1,
1835 -30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1,
1836 -31,-30,-29,-28,-27,-26,-25,-24,-23,-22,-21,-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1,
1837 -31,-30,-29,-28,-27,-26,-25
1838 ];
1839
1840 const WEEKDAY_MASK = [
1841 1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,
1842 1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,
1843 1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,
1844 1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,
1845 1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,
1846 1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,
1847 1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,
1848 1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,
1849 1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,
1850 1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,
1851 1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7,1,2,3,4,5,6,7
1852 ];
1853
1854 const LAST_DAY_OF_MONTH_366 = [
1855 0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335, 366
1856 ];
1857
1858 const LAST_DAY_OF_MONTH = [
1859 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365
1860 ];
1861
1862 /**
1863 * @var array
1864 * Maximum number of cycles after which a calendar repeats itself. This
1865 * is used to detect infinite loop: if no occurrence has been found
1866 * after this numbers of cycles, we can abort.
1867 *
1868 * The Gregorian calendar cycle repeat completely every 400 years
1869 * (146,097 days or 20,871 weeks).
1870 * A smaller cycle would be 28 years (1,461 weeks), but it only works
1871 * if there is no dropped leap year in between.
1872 * 2100 will be a dropped leap year, but I'm going to assume it's not
1873 * going to be a problem anytime soon, so at the moment I use the 28 years
1874 * cycle.
1875 */
1876 const MAX_CYCLES = [
1877 // self::YEARLY => 400,
1878 // self::MONTHLY => 4800,
1879 // self::WEEKLY => 20871,
1880 // self::DAILY => 146097, // that's a lot of cycles, it takes a few seconds to detect infinite loop
1881 self::YEARLY => 28,
1882 self::MONTHLY => 336,
1883 self::WEEKLY => 1461,
1884 self::DAILY => 10227,
1885
1886 self::HOURLY => 24,
1887 self::MINUTELY => 1440,
1888 self::SECONDLY => 86400 // that's a lot of cycles too
1889 ];
1890
1891 ///////////////////////////////////////////////////////////////////////////////
1892 // i18n methods
1893 // these could be moved into a separate class maybe, since it's not always necessary
1894
1895 /**
1896 * @var array Stores translations once loaded (so we don't have to reload them all the time)
1897 */
1898 static protected $i18n = array();
1899
1900 /**
1901 * @var bool if intl extension is loaded
1902 */
1903 static protected $intl_loaded = null;
1904
1905 /**
1906 * Select a translation in $array based on the value of $n
1907 *
1908 * Used for selecting plural forms.
1909 *
1910 * @param mixed $array Array with multiple forms or a string
1911 * @param string $n
1912 *
1913 * @return string
1914 */
1915 static protected function i18nSelect($array, $n)
1916 {
1917 if (! is_array($array)) {
1918 return $array;
1919 }
1920
1921 if (array_key_exists($n, $array)) {
1922 return $array[$n];
1923 }
1924 elseif (array_key_exists('else', $array)) {
1925 return $array['else'];
1926 }
1927 else {
1928 return ''; // or throw?
1929 }
1930 }
1931
1932 /**
1933 * Create a comma-separated list, with the last item added with an " and "
1934 * Example: Monday, Tuesday and Friday
1935 *
1936 * @param array $array
1937 * @param string $and Translation for "and"
1938 *
1939 * @return string
1940 */
1941 static protected function i18nList(array $array, $and = 'and ')
1942 {
1943 if (count($array) > 1) {
1944 $last = array_splice($array, -1);
1945 return sprintf(
1946 '%s %s%s',
1947 implode(', ',$array),
1948 $and,
1949 implode('',$last)
1950 );
1951 }
1952 else {
1953 return $array[0];
1954 }
1955 }
1956
1957 /**
1958 * Test if intl extension is loaded
1959 * @return bool
1960 */
1961 static protected function intlLoaded()
1962 {
1963 if (self::$intl_loaded === null) {
1964 self::$intl_loaded = extension_loaded('intl');
1965 }
1966 return self::$intl_loaded;
1967 }
1968
1969 /**
1970 * Parse a locale and returns a list of files to load.
1971 * For example "fr_FR" will produce "fr" and "fr_FR"
1972 *
1973 * @param $locale
1974 * @param null $use_intl
1975 *
1976 * @return array
1977 */
1978 static protected function i18nFilesToLoad($locale, $use_intl = null)
1979 {
1980 if ($use_intl === null) {
1981 $use_intl = self::intlLoaded();
1982 }
1983 $files = array();
1984
1985 if ($use_intl) {
1986 $parsed = \Locale::parseLocale($locale);
1987 $files[] = $parsed['language'];
1988 if (isset($parsed['region'])) {
1989 $files[] = $parsed['language'].'_'.$parsed['region'];
1990 }
1991 }
1992 else {
1993 if (! preg_match('/^([a-z]{2})(?:(?:_|-)[A-Z][a-z]+)?(?:(?:_|-)([A-Za-z]{2}))?(?:(?:_|-)[A-Z]*)?(?:\.[a-zA-Z\-0-9]*)?$/', $locale, $matches)) {
1994 throw new \InvalidArgumentException('The locale option does not look like a valid locale: ' . esc_html($locale) . '. For more option install the intl extension.');
1995 }
1996
1997 $files[] = $matches[1];
1998 if (isset($matches[2])) {
1999 $files[] = $matches[1].'_'.strtoupper($matches[2]);
2000 }
2001 }
2002
2003 return $files;
2004 }
2005
2006 /**
2007 * Load a translation file in memory.
2008 * Will load the basic first (e.g. "en") and then the region-specific if any
2009 * (e.g. "en_GB"), merging as necessary.
2010 * So region-specific translation files don't need to redefine every strings.
2011 *
2012 * @param string $locale
2013 * @param string|null $fallback
2014 * @param bool $use_intl
2015 * @param string $custom_path
2016 *
2017 * @return array
2018 * @throws \InvalidArgumentException
2019 */
2020 static protected function i18nLoad($locale, $fallback = null, $use_intl = null, $custom_path = null)
2021 {
2022 $files = self::i18nFilesToLoad($locale, $use_intl);
2023
2024 $base_path = __DIR__.'/i18n';
2025
2026 $result = array();
2027 foreach ($files as $file) {
2028
2029 // if the file exists in $custom_path, it overrides the default
2030 if ($custom_path && is_file("$custom_path/$file.php")) {
2031 $path = "$custom_path/$file.php";
2032 }
2033 else {
2034 $path = "$base_path/$file.php";
2035 }
2036
2037 if (isset(self::$i18n[$path])) {
2038 $result = array_merge($result, self::$i18n[$path]);
2039 }
2040 elseif (is_file($path) && is_readable($path)) {
2041 self::$i18n[$path] = include $path;
2042 $result = array_merge($result, self::$i18n[$path]);
2043 }
2044 else {
2045 self::$i18n[$path] = array();
2046 }
2047 }
2048
2049 if (empty($result)) {
2050 if (!is_null($fallback)) {
2051 return self::i18nLoad($fallback, null, $use_intl);
2052 }
2053 throw new \RuntimeException('Failed to load translations for ' . esc_html($locale));
2054 }
2055
2056 return $result;
2057 }
2058
2059 /**
2060 * Format a rule in a human readable string
2061 * `intl` extension is required.
2062 *
2063 * Available options
2064 *
2065 * | Name | Type | Description
2066 * |-------------------|---------|------------
2067 * | `use_intl` | bool | Use the intl extension or not (autodetect)
2068 * | `locale` | string | The locale to use (autodetect)
2069 * | `fallback` | string | Fallback locale if main locale is not found (default en)
2070 * | `date_formatter` | callable| Function used to format the date (takes date, returns formatted)
2071 * | `explicit_inifite`| bool | Mention "forever" if the rule is infinite (true)
2072 * | `dtstart` | bool | Mention the start date (true)
2073 * | `include_start` | bool |
2074 * | `include_until` | bool |
2075 * | `custom_path` | string |
2076 *
2077 * @param array $opt
2078 *
2079 * @return string
2080 */
2081 public function humanReadable(array $opt = array())
2082 {
2083 if (! isset($opt['use_intl'])) {
2084 $opt['use_intl'] = self::intlLoaded();
2085 }
2086
2087 $default_opt = array(
2088 'use_intl' => self::intlLoaded(),
2089 'locale' => null,
2090 'date_formatter' => null,
2091 'fallback' => 'en',
2092 'explicit_infinite' => true,
2093 'include_start' => true,
2094 'include_until' => true,
2095 'custom_path' => null
2096 );
2097
2098 // attempt to detect default locale
2099 if ($opt['use_intl']) {
2100 $default_opt['locale'] = \Locale::getDefault();
2101 } else {
2102 $default_opt['locale'] = setlocale(LC_CTYPE, 0);
2103 if ($default_opt['locale'] == 'C') {
2104 $default_opt['locale'] = 'en';
2105 }
2106 }
2107
2108 if ($opt['use_intl']) {
2109 $default_opt['date_format'] = \IntlDateFormatter::SHORT;
2110 if ($this->freq >= self::SECONDLY || not_empty($this->rule['BYSECOND'])) {
2111 $default_opt['time_format'] = \IntlDateFormatter::LONG;
2112 }
2113 elseif ($this->freq >= self::HOURLY || not_empty($this->rule['BYHOUR']) || not_empty($this->rule['BYMINUTE'])) {
2114 $default_opt['time_format'] = \IntlDateFormatter::SHORT;
2115 }
2116 else {
2117 $default_opt['time_format'] = \IntlDateFormatter::NONE;
2118 }
2119 }
2120
2121 $opt = array_merge($default_opt, $opt);
2122
2123 $i18n = self::i18nLoad($opt['locale'], $opt['fallback'], $opt['use_intl'], $opt['custom_path']);
2124
2125 if ($opt['date_formatter'] && ! is_callable($opt['date_formatter'])) {
2126 throw new \InvalidArgumentException('The option date_formatter must callable');
2127 }
2128
2129 if (! $opt['date_formatter']) {
2130 if ($opt['use_intl']) {
2131 $timezone = $this->dtstart->getTimezone()->getName();
2132
2133 if ($timezone === 'Z') {
2134 $timezone = 'GMT'; // otherwise IntlDateFormatter::create fails because... reasons.
2135 } elseif (preg_match('/[-+]\d{2}/',$timezone)) {
2136 $timezone = 'GMT'.$timezone; // otherwise IntlDateFormatter::create fails because... other reasons.
2137 }
2138 $formatter = \IntlDateFormatter::create(
2139 $opt['locale'],
2140 $opt['date_format'],
2141 $opt['time_format'],
2142 $timezone
2143 );
2144 if (! $formatter) {
2145 throw new \RuntimeException('IntlDateFormatter::create() failed. Error Code: '.esc_html(intl_get_error_code()).' "'. esc_html(intl_get_error_message()).'" (this should not happen, please open a bug report!)');
2146 }
2147 $opt['date_formatter'] = function($date) use ($formatter) {
2148 return $formatter->format($date);
2149 };
2150 }
2151 else {
2152 $opt['date_formatter'] = function($date) {
2153 return $date->format('Y-m-d H:i:s');
2154 };
2155 }
2156 }
2157
2158 $parts = array(
2159 'freq' => '',
2160 'byweekday' => '',
2161 'bymonth' => '',
2162 'byweekno' => '',
2163 'byyearday' => '',
2164 'bymonthday' => '',
2165 'byhour' => '',
2166 'byminute' => '',
2167 'bysecond' => '',
2168 'bysetpos' => ''
2169 );
2170
2171 // Every (INTERVAL) FREQ...
2172 $freq_str = strtolower(array_search($this->freq, self::FREQUENCIES));
2173 $parts['freq'] = strtr(
2174 self::i18nSelect($i18n[$freq_str], $this->interval),
2175 array(
2176 '%{interval}' => $this->interval
2177 )
2178 );
2179
2180 // BYXXX rules
2181 if (not_empty($this->rule['BYMONTH'])) {
2182 $tmp = $this->bymonth;
2183 foreach ($tmp as & $value) {
2184 $value = $i18n['months'][$value];
2185 }
2186 $parts['bymonth'] = strtr(self::i18nSelect($i18n['bymonth'], count($tmp)), array(
2187 '%{months}' => self::i18nList($tmp, $i18n['and'])
2188 ));
2189
2190 if ($freq_str == 'yearly') {
2191 // if a yearly frequency is being displayed by month, then switch "of the year" text to be monthly
2192 $freq_str = 'monthly';
2193 }
2194 }
2195
2196 if (not_empty($this->rule['BYWEEKNO'])) {
2197 // XXX negative week number are not great here
2198 $tmp = $this->byweekno;
2199 foreach ($tmp as & $value) {
2200 $value = strtr($i18n['nth_weekno'], array(
2201 '%{n}' => $value
2202 ));
2203 }
2204 $parts['byweekno'] = strtr(
2205 self::i18nSelect($i18n['byweekno'], count($this->byweekno)),
2206 array(
2207 '%{weeks}' => self::i18nList($tmp, $i18n['and'])
2208 )
2209 );
2210 }
2211
2212 if (not_empty($this->rule['BYYEARDAY'])) {
2213 $tmp = $this->byyearday;
2214 foreach ($tmp as & $value) {
2215 $value = strtr(self::i18nSelect($i18n[$value>0?'nth_yearday':'-nth_yearday'],$value), array(
2216 '%{n}' => abs($value)
2217 ));
2218 }
2219 $tmp = strtr(self::i18nSelect($i18n['byyearday'], count($tmp)), array(
2220 '%{yeardays}' => self::i18nList($tmp, $i18n['and'])
2221 ));
2222 // ... of the month
2223 $tmp = strtr(self::i18nSelect($i18n['x_of_the_y'], 'yearly'), array(
2224 '%{x}' => $tmp
2225 ));
2226 $parts['byyearday'] = $tmp;
2227 }
2228
2229 if (not_empty($this->rule['BYMONTHDAY'])) {
2230 $parts['bymonthday'] = array();
2231 if ($this->bymonthday) {
2232 $tmp = $this->bymonthday;
2233 foreach ($tmp as & $value) {
2234 $value = strtr(self::i18nSelect($i18n['nth_monthday'],$value), array(
2235 '%{n}' => $value
2236 ));
2237 }
2238 $tmp = strtr(self::i18nSelect($i18n['bymonthday'], count($tmp)), array(
2239 '%{monthdays}' => self::i18nList($tmp, $i18n['and'])
2240 ));
2241 // ... of the month
2242 $tmp = strtr(self::i18nSelect($i18n['x_of_the_y'], 'monthly'), array(
2243 '%{x}' => $tmp
2244 ));
2245 $parts['bymonthday'][] = $tmp;
2246 }
2247 if ($this->bymonthday_negative) {
2248 $tmp = $this->bymonthday_negative;
2249 foreach ($tmp as & $value) {
2250 $value = strtr(self::i18nSelect($i18n['-nth_monthday'],$value), array(
2251 '%{n}' => -$value
2252 ));
2253 }
2254 $tmp = strtr(self::i18nSelect($i18n['bymonthday'], count($tmp)), array(
2255 '%{monthdays}' => self::i18nList($tmp, $i18n['and'])
2256 ));
2257 // ... of the month
2258 $tmp = strtr(self::i18nSelect($i18n['x_of_the_y'], 'monthly'), array(
2259 '%{x}' => $tmp
2260 ));
2261 $parts['bymonthday'][] = $tmp;
2262 }
2263 // because the 'on the Xth day' strings start with the space, and the "and" ends with a space
2264 // it's necessary to collapse double spaces into one
2265 // this behaviour was introduced in https://github.com/rlanvin/php-rrule/pull/95
2266 $parts['bymonthday'] = str_replace(' ',' ',implode(' '.$i18n['and'],$parts['bymonthday']));
2267 }
2268
2269 if (not_empty($this->rule['BYDAY'])) {
2270 $parts['byweekday'] = array();
2271 if ($this->byweekday) {
2272 $tmp = $this->byweekday;
2273
2274 $selector = 'weekdays';
2275 $days_names = $i18n['weekdays'];
2276 $prefix = '';
2277 if (!empty($i18n['shorten_weekdays_in_list']) && count($tmp) > 1) {
2278 // special case for Hebrew (and possibly other languages)
2279 // see https://github.com/rlanvin/php-rrule/pull/95 for the reasoning
2280 $selector = 'weekdays_shortened_for_list';
2281 $prefix = $i18n['shorten_weekdays_days'];
2282 }
2283
2284 foreach ($tmp as & $value) {
2285 $value = $i18n[$selector][$value];
2286 }
2287
2288 $parts['byweekday'][] = strtr(self::i18nSelect($i18n['byweekday'], count($tmp)), array(
2289 '%{weekdays}' => $prefix . self::i18nList($tmp, $i18n['and'])
2290 ));
2291 }
2292
2293 if ($this->byweekday_nth) {
2294 $tmp = $this->byweekday_nth;
2295 foreach ($tmp as & $value) {
2296 list($day, $n) = $value;
2297 $value = strtr(self::i18nSelect($i18n[$n>0?'nth_weekday':'-nth_weekday'], $n), array(
2298 '%{weekday}' => $i18n['weekdays'][$day],
2299 '%{n}' => abs($n)
2300 ));
2301 }
2302 $tmp = strtr(self::i18nSelect($i18n['byweekday'], count($tmp)), array(
2303 '%{weekdays}' => self::i18nList($tmp, $i18n['and'])
2304 ));
2305 // ... of the year|month
2306 $tmp = strtr(self::i18nSelect($i18n['x_of_the_y'], $freq_str), array(
2307 '%{x}' => $tmp
2308 ));
2309 $parts['byweekday'][] = $tmp;
2310 }
2311 $parts['byweekday'] = implode(' '.$i18n['and'],$parts['byweekday']);
2312 }
2313
2314 if (not_empty($this->rule['BYHOUR'])) {
2315 $tmp = $this->byhour;
2316 foreach ($tmp as &$value) {
2317 $value = strtr($i18n['nth_hour'], array(
2318 '%{n}' => $value
2319 ));
2320 }
2321 $parts['byhour'] = strtr(self::i18nSelect($i18n['byhour'],count($tmp)), array(
2322 '%{hours}' => self::i18nList($tmp, $i18n['and'])
2323 ));
2324 }
2325
2326 if (not_empty($this->rule['BYMINUTE'])) {
2327 $tmp = $this->byminute;
2328 foreach ($tmp as &$value) {
2329 $value = strtr($i18n['nth_minute'], array(
2330 '%{n}' => $value
2331 ));
2332 }
2333 $parts['byminute'] = strtr(self::i18nSelect($i18n['byminute'],count($tmp)), array(
2334 '%{minutes}' => self::i18nList($tmp, $i18n['and'])
2335 ));
2336 }
2337
2338 if (not_empty($this->rule['BYSECOND'])) {
2339 $tmp = $this->bysecond;
2340 foreach ($tmp as &$value) {
2341 $value = strtr($i18n['nth_second'], array(
2342 '%{n}' => $value
2343 ));
2344 }
2345 $parts['bysecond'] = strtr(self::i18nSelect($i18n['bysecond'],count($tmp)), array(
2346 '%{seconds}' => self::i18nList($tmp, $i18n['and'])
2347 ));
2348 }
2349
2350 if ($this->bysetpos) {
2351 $tmp = $this->bysetpos;
2352 foreach ($tmp as & $value) {
2353 $value = strtr(self::i18nSelect($i18n[$value>0?'nth_setpos':'-nth_setpos'],$value), array(
2354 '%{n}' => abs($value)
2355 ));
2356 }
2357 $tmp = strtr(self::i18nSelect($i18n['bysetpos'], count($tmp)), array(
2358 '%{setpos}' => self::i18nList($tmp, $i18n['and'])
2359 ));
2360 $parts['bysetpos'] = $tmp;
2361 }
2362
2363 if ($opt['include_start']) {
2364 // from X
2365 $parts['start'] = strtr($i18n['dtstart'], array(
2366 '%{date}' => $opt['date_formatter']($this->dtstart)
2367 ));
2368 }
2369
2370 // to X, or N times, or indefinitely
2371 if ($opt['include_until']) {
2372 if (! $this->until && ! $this->count) {
2373 if ($opt['explicit_infinite']) {
2374 $parts['end'] = $i18n['infinite'];
2375 }
2376 }
2377 elseif ($this->until) {
2378 $parts['end'] = strtr($i18n['until'], array(
2379 '%{date}' => $opt['date_formatter']($this->until)
2380 ));
2381 }
2382 elseif ($this->count) {
2383 $parts['end'] = strtr(
2384 self::i18nSelect($i18n['count'], $this->count),
2385 array(
2386 '%{count}' => $this->count
2387 )
2388 );
2389 }
2390 }
2391
2392 $parts = array_filter($parts);
2393 $str = implode('',$parts);
2394 return $str;
2395 }
2396 }
2397