1<?php
2
3/*
4 * This file is part of the eluceo/iCal package.
5 *
6 * (c) Markus Poerschke <markus@eluceo.de>
7 *
8 * This source file is subject to the MIT license that is bundled
9 * with this source code in the file LICENSE.
10 */
11
12namespace Eluceo\iCal\Property\Event;
13
14use Eluceo\iCal\ParameterBag;
15use Eluceo\iCal\Property\ValueInterface;
16use InvalidArgumentException;
17
18/**
19 * Implementation of Recurrence Rule.
20 *
21 * @see https://tools.ietf.org/html/rfc5545#section-3.8.5.3
22 */
23class RecurrenceRule implements ValueInterface
24{
25    const FREQ_YEARLY = 'YEARLY';
26    const FREQ_MONTHLY = 'MONTHLY';
27    const FREQ_WEEKLY = 'WEEKLY';
28    const FREQ_DAILY = 'DAILY';
29    const FREQ_HOURLY = 'HOURLY';
30    const FREQ_MINUTELY = 'MINUTELY';
31    const FREQ_SECONDLY = 'SECONDLY';
32
33    const WEEKDAY_SUNDAY = 'SU';
34    const WEEKDAY_MONDAY = 'MO';
35    const WEEKDAY_TUESDAY = 'TU';
36    const WEEKDAY_WEDNESDAY = 'WE';
37    const WEEKDAY_THURSDAY = 'TH';
38    const WEEKDAY_FRIDAY = 'FR';
39    const WEEKDAY_SATURDAY = 'SA';
40
41    /**
42     * The frequency of an Event.
43     *
44     * @var string
45     */
46    protected $freq = self::FREQ_YEARLY;
47
48    /**
49     * BYSETPOS must require use of other BY*.
50     *
51     * @var bool
52     */
53    protected $canUseBySetPos = false;
54
55    /**
56     * @var int|null
57     */
58    protected $interval = 1;
59
60    /**
61     * @var int|null
62     */
63    protected $count = null;
64
65    /**
66     * @var \DateTimeInterface|null
67     */
68    protected $until = null;
69
70    /**
71     * @var string|null
72     */
73    protected $wkst;
74
75    /**
76     * @var array|null
77     */
78    protected $bySetPos = null;
79
80    /**
81     * @var string|null
82     */
83    protected $byMonth;
84
85    /**
86     * @var string|null
87     */
88    protected $byWeekNo;
89
90    /**
91     * @var string|null
92     */
93    protected $byYearDay;
94
95    /**
96     * @var string|null
97     */
98    protected $byMonthDay;
99
100    /**
101     * @var string|null
102     */
103    protected $byDay;
104
105    /**
106     * @var string|null
107     */
108    protected $byHour;
109
110    /**
111     * @var string|null
112     */
113    protected $byMinute;
114
115    /**
116     * @var string|null
117     */
118    protected $bySecond;
119
120    public function getEscapedValue(): string
121    {
122        return $this->buildParameterBag()->toString();
123    }
124
125    /**
126     * @return ParameterBag
127     */
128    protected function buildParameterBag()
129    {
130        $parameterBag = new ParameterBag();
131
132        $parameterBag->setParam('FREQ', $this->freq);
133
134        if (null !== $this->interval) {
135            $parameterBag->setParam('INTERVAL', $this->interval);
136        }
137
138        if (null !== $this->count) {
139            $parameterBag->setParam('COUNT', $this->count);
140        }
141
142        if (null != $this->until) {
143            $parameterBag->setParam('UNTIL', $this->until->format('Ymd\THis\Z'));
144        }
145
146        if (null !== $this->wkst) {
147            $parameterBag->setParam('WKST', $this->wkst);
148        }
149
150        if (null !== $this->bySetPos && $this->canUseBySetPos) {
151            $parameterBag->setParam('BYSETPOS', $this->bySetPos);
152        }
153
154        if (null !== $this->byMonth) {
155            $parameterBag->setParam('BYMONTH', explode(',', $this->byMonth));
156        }
157
158        if (null !== $this->byWeekNo) {
159            $parameterBag->setParam('BYWEEKNO', explode(',', $this->byWeekNo));
160        }
161
162        if (null !== $this->byYearDay) {
163            $parameterBag->setParam('BYYEARDAY', explode(',', $this->byYearDay));
164        }
165
166        if (null !== $this->byMonthDay) {
167            $parameterBag->setParam('BYMONTHDAY', explode(',', $this->byMonthDay));
168        }
169
170        if (null !== $this->byDay) {
171            $parameterBag->setParam('BYDAY', explode(',', $this->byDay));
172        }
173
174        if (null !== $this->byHour) {
175            $parameterBag->setParam('BYHOUR', explode(',', $this->byHour));
176        }
177
178        if (null !== $this->byMinute) {
179            $parameterBag->setParam('BYMINUTE', explode(',', $this->byMinute));
180        }
181
182        if (null !== $this->bySecond) {
183            $parameterBag->setParam('BYSECOND', explode(',', $this->bySecond));
184        }
185
186        return $parameterBag;
187    }
188
189    /**
190     * @param int|null $count
191     *
192     * @return $this
193     */
194    public function setCount($count)
195    {
196        $this->count = $count;
197
198        return $this;
199    }
200
201    /**
202     * @return int|null
203     */
204    public function getCount()
205    {
206        return $this->count;
207    }
208
209    /**
210     * @return $this
211     */
212    public function setUntil(\DateTimeInterface $until = null)
213    {
214        $this->until = $until;
215
216        return $this;
217    }
218
219    /**
220     * @return \DateTimeInterface|null
221     */
222    public function getUntil()
223    {
224        return $this->until;
225    }
226
227    /**
228     * The FREQ rule part identifies the type of recurrence rule.  This
229     * rule part MUST be specified in the recurrence rule.  Valid values
230     * include.
231     *
232     * SECONDLY, to specify repeating events based on an interval of a second or more;
233     * MINUTELY, to specify repeating events based on an interval of a minute or more;
234     * HOURLY, to specify repeating events based on an interval of an hour or more;
235     * DAILY, to specify repeating events based on an interval of a day or more;
236     * WEEKLY, to specify repeating events based on an interval of a week or more;
237     * MONTHLY, to specify repeating events based on an interval of a month or more;
238     * YEARLY, to specify repeating events based on an interval of a year or more.
239     *
240     * @param string $freq
241     *
242     * @return $this
243     *
244     * @throws \InvalidArgumentException
245     */
246    public function setFreq($freq)
247    {
248        if (@constant('static::FREQ_' . $freq) !== null) {
249            $this->freq = $freq;
250        } else {
251            throw new \InvalidArgumentException("The Frequency {$freq} is not supported.");
252        }
253
254        return $this;
255    }
256
257    /**
258     * @return string
259     */
260    public function getFreq()
261    {
262        return $this->freq;
263    }
264
265    /**
266     * The INTERVAL rule part contains a positive integer representing at
267     * which intervals the recurrence rule repeats.
268     *
269     * @param int|null $interval
270     *
271     * @return $this
272     */
273    public function setInterval($interval)
274    {
275        $this->interval = $interval;
276
277        return $this;
278    }
279
280    /**
281     * @return int|null
282     */
283    public function getInterval()
284    {
285        return $this->interval;
286    }
287
288    /**
289     * The WKST rule part specifies the day on which the workweek starts.
290     * Valid values are MO, TU, WE, TH, FR, SA, and SU.
291     *
292     * @param string $value
293     *
294     * @return $this
295     */
296    public function setWkst($value)
297    {
298        $this->wkst = $value;
299
300        return $this;
301    }
302
303    /**
304     * The BYSETPOS filters one interval of events by the specified position.
305     * A positive position will start from the beginning and go forward while
306     * a negative position will start at the end and move backward.
307     *
308     * Valid values are a comma separated string or an array of integers
309     * from 1 to 366 or negative integers from -1 to -366.
310     *
311     * @param int|string|array|null $value
312     *
313     * @throws InvalidArgumentException
314     *
315     * @return $this
316     */
317    public function setBySetPos($value)
318    {
319        if (null === $value) {
320            $this->bySetPos = $value;
321
322            return $this;
323        }
324
325        if (!(is_string($value) || is_array($value) || is_int($value))) {
326            throw new InvalidArgumentException('Invalid value for BYSETPOS');
327        }
328
329        $list = $value;
330
331        if (is_int($value)) {
332            if ($value === 0 || $value < -366 || $value > 366) {
333                throw new InvalidArgumentException('Invalid value for BYSETPOS');
334            }
335            $this->bySetPos = [$value];
336
337            return $this;
338        }
339
340        if (is_string($value)) {
341            $list = explode(',', $value);
342        }
343
344        $output = [];
345
346        foreach ($list as $item) {
347            if (is_string($item)) {
348                if (!preg_match('/^ *-?[0-9]* *$/', $item)) {
349                    throw new InvalidArgumentException('Invalid value for BYSETPOS');
350                }
351                $item = intval($item);
352            }
353
354            if (!is_int($item) || $item === 0 || $item < -366 || $item > 366) {
355                throw new InvalidArgumentException('Invalid value for BYSETPOS');
356            }
357
358            $output[] = $item;
359        }
360
361        $this->bySetPos = $output;
362
363        return $this;
364    }
365
366    /**
367     * The BYMONTH rule part specifies a COMMA-separated list of months of the year.
368     * Valid values are 1 to 12.
369     *
370     * @param int $month
371     *
372     * @throws \InvalidArgumentException
373     *
374     * @return $this
375     */
376    public function setByMonth($month)
377    {
378        if (!is_integer($month) || $month <= 0 || $month > 12) {
379            throw new InvalidArgumentException('Invalid value for BYMONTH');
380        }
381
382        $this->byMonth = $month;
383
384        $this->canUseBySetPos = true;
385
386        return $this;
387    }
388
389    /**
390     * The BYWEEKNO rule part specifies a COMMA-separated list of ordinals specifying weeks of the year.
391     * Valid values are 1 to 53 or -53 to -1.
392     *
393     * @param int $value
394     *
395     * @throws \InvalidArgumentException
396     *
397     * @return $this
398     */
399    public function setByWeekNo($value)
400    {
401        if (!is_integer($value) || $value > 53 || $value < -53 || $value === 0) {
402            throw new InvalidArgumentException('Invalid value for BYWEEKNO');
403        }
404
405        $this->byWeekNo = $value;
406
407        $this->canUseBySetPos = true;
408
409        return $this;
410    }
411
412    /**
413     * The BYYEARDAY rule part specifies a COMMA-separated list of days of the year.
414     * Valid values are 1 to 366 or -366 to -1.
415     *
416     * @param int $day
417     *
418     * @throws \InvalidArgumentException
419     *
420     * @return $this
421     */
422    public function setByYearDay($day)
423    {
424        if (!is_integer($day) || $day > 366 || $day < -366 || $day === 0) {
425            throw new InvalidArgumentException('Invalid value for BYYEARDAY');
426        }
427
428        $this->byYearDay = $day;
429
430        $this->canUseBySetPos = true;
431
432        return $this;
433    }
434
435    /**
436     * The BYMONTHDAY rule part specifies a COMMA-separated list of days of the month.
437     * Valid values are 1 to 31 or -31 to -1.
438     *
439     * @param int $day
440     *
441     * @return $this
442     *
443     * @throws \InvalidArgumentException
444     */
445    public function setByMonthDay($day)
446    {
447        if (!is_integer($day) || $day > 31 || $day < -31 || $day === 0) {
448            throw new InvalidArgumentException('Invalid value for BYMONTHDAY');
449        }
450
451        $this->byMonthDay = $day;
452
453        $this->canUseBySetPos = true;
454
455        return $this;
456    }
457
458    /**
459     * The BYDAY rule part specifies a COMMA-separated list of days of the week;.
460     *
461     * SU indicates Sunday; MO indicates Monday; TU indicates Tuesday;
462     * WE indicates Wednesday; TH indicates Thursday; FR indicates Friday; and SA indicates Saturday.
463     *
464     * Each BYDAY value can also be preceded by a positive (+n) or negative (-n) integer.
465     * If present, this indicates the nth occurrence of a specific day within the MONTHLY or YEARLY "RRULE".
466     *
467     * @return $this
468     */
469    public function setByDay(string $day)
470    {
471        $this->byDay = $day;
472
473        $this->canUseBySetPos = true;
474
475        return $this;
476    }
477
478    /**
479     * The BYHOUR rule part specifies a COMMA-separated list of hours of the day.
480     * Valid values are 0 to 23.
481     *
482     * @param int $value
483     *
484     * @return $this
485     *
486     * @throws \InvalidArgumentException
487     */
488    public function setByHour($value)
489    {
490        if (!is_integer($value) || $value < 0 || $value > 23) {
491            throw new \InvalidArgumentException('Invalid value for BYHOUR');
492        }
493
494        $this->byHour = $value;
495
496        $this->canUseBySetPos = true;
497
498        return $this;
499    }
500
501    /**
502     * The BYMINUTE rule part specifies a COMMA-separated list of minutes within an hour.
503     * Valid values are 0 to 59.
504     *
505     * @param int $value
506     *
507     * @return $this
508     *
509     * @throws \InvalidArgumentException
510     */
511    public function setByMinute($value)
512    {
513        if (!is_integer($value) || $value < 0 || $value > 59) {
514            throw new \InvalidArgumentException('Invalid value for BYMINUTE');
515        }
516
517        $this->byMinute = $value;
518
519        $this->canUseBySetPos = true;
520
521        return $this;
522    }
523
524    /**
525     * The BYSECOND rule part specifies a COMMA-separated list of seconds within a minute.
526     * Valid values are 0 to 60.
527     *
528     * @param int $value
529     *
530     * @return $this
531     *
532     * @throws \InvalidArgumentException
533     */
534    public function setBySecond($value)
535    {
536        if (!is_integer($value) || $value < 0 || $value > 60) {
537            throw new \InvalidArgumentException('Invalid value for BYSECOND');
538        }
539
540        $this->bySecond = $value;
541
542        $this->canUseBySetPos = true;
543
544        return $this;
545    }
546}
547