Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.38% covered (success)
94.38%
84 / 89
50.00% covered (danger)
50.00%
4 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
RecurrenceEngine
94.32% covered (success)
94.32%
83 / 88
50.00% covered (danger)
50.00%
4 / 8
46.39
0.00% covered (danger)
0.00%
0 / 1
 parseRule
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 buildRule
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
8.02
 expandInstances
96.15% covered (success)
96.15%
25 / 26
0.00% covered (danger)
0.00%
0 / 1
10
 shouldStopRecurrence
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
5.20
 matchesOccurrence
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 buildInstanceRecord
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 parseExceptions
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 advanceDate
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
6.84
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\Calendar\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use DateInterval;
12use DateTimeImmutable;
13use DateTimeInterface;
14
15/**
16 * Modern Recurrence Engine compatible with CalDAV / RFC 5545 iCalendar standard.
17 *
18 * Handles parsing, generation, expansion and exception handling of recurring events.
19 *
20 * @package App\Modules\Calendar\Application\Service
21 */
22final class RecurrenceEngine
23{
24    /** Supported RFC 5545 weekday codes. */
25    public const array RFC_WEEKDAYS = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'];
26
27    /** Map of RFC 5545 weekdays to PHP day of week numbers (1=Mon, 7=Sun). */
28    public const array RFC_TO_NUMERIC_WEEKDAY = [
29        'MO' => 1, 'TU' => 2, 'WE' => 3, 'TH' => 4,
30        'FR' => 5, 'SA' => 6, 'SU' => 7,
31    ];
32
33    /**
34     * Parses an RFC 5545 RRULE string into an associative array.
35     *
36     * @param string $rrule Raw RRULE (e.g. "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE;COUNT=10").
37     * @return array<string, string> Parsed components keyed by uppercase parameter name.
38     */
39    public function parseRule(string $rrule): array
40    {
41        $clean = trim(str_replace('RRULE:', '', $rrule));
42        if ($clean === '') {
43            return [];
44        }
45
46        $parts = explode(';', $clean);
47        $result = [];
48
49        foreach ($parts as $part) {
50            $kv = explode('=', $part, 2);
51            if (count($kv) === 2) {
52                $result[strtoupper(trim($kv[0]))] = trim($kv[1]);
53            }
54        }
55
56        return $result;
57    }
58
59    /**
60     * Builds a standard RFC 5545 RRULE string from parameters.
61     *
62     * @param array<string, mixed> $params Rule parameters (freq, interval, count, until, byday).
63     * @return string Formatted RRULE string.
64     */
65    public function buildRule(array $params): string
66    {
67        $freq = strtoupper((string) ($params['freq'] ?? $params['FREQ'] ?? 'WEEKLY'));
68        $interval = max(1, (int) ($params['interval'] ?? $params['INTERVAL'] ?? 1));
69
70        $tokens = ["FREQ={$freq}"];
71        if ($interval > 1) {
72            $tokens[] = "INTERVAL={$interval}";
73        }
74
75        $byDay = $params['byday'] ?? $params['BYDAY'] ?? null;
76        if (!empty($byDay)) {
77            $byDayStr = is_array($byDay) ? implode(',', $byDay) : (string) $byDay;
78            $tokens[] = 'BYDAY=' . strtoupper(trim($byDayStr));
79        }
80
81        $until = $params['until'] ?? $params['UNTIL'] ?? null;
82        if (!empty($until)) {
83            $tokens[] = 'UNTIL=' . trim((string) $until);
84        }
85
86        $count = $params['count'] ?? $params['COUNT'] ?? null;
87        if (!empty($count) && (int) $count > 0 && empty($until)) {
88            $tokens[] = 'COUNT=' . (int) $count;
89        }
90
91        return implode(';', $tokens);
92    }
93
94    /**
95     * Expands a master recurring event into occurrences within the given date window.
96     *
97     * @param array<string, mixed> $master Master calendar event record.
98     * @param DateTimeInterface $rangeStart Window start.
99     * @param DateTimeInterface $rangeEnd Window end.
100     * @param int $maxOccurrences Safety ceiling for occurrences.
101     * @return array<int, array<string, mixed>> Array of event occurrences.
102     */
103    public function expandInstances(
104        array $master,
105        DateTimeInterface $rangeStart,
106        DateTimeInterface $rangeEnd,
107        int $maxOccurrences = 200
108    ): array {
109        $rrule = (string) ($master['recurrence_rule'] ?? '');
110        $startStr = (string) ($master['start_date'] ?? '');
111        if ($rrule === '' || $startStr === '') {
112            return [$master];
113        }
114
115        $params = $this->parseRule($rrule);
116        $freq = $params['FREQ'] ?? 'WEEKLY';
117        $interval = max(1, (int) ($params['INTERVAL'] ?? 1));
118        $byDay = isset($params['BYDAY']) ? explode(',', $params['BYDAY']) : [];
119        $until = isset($params['UNTIL']) ? new DateTimeImmutable($params['UNTIL']) : null;
120        $maxCount = isset($params['COUNT']) ? (int) $params['COUNT'] : null;
121
122        $origStart = new DateTimeImmutable($startStr);
123        $endStr = (string) ($master['end_date'] ?? '');
124        $origEnd = $endStr !== '' ? new DateTimeImmutable($endStr) : $origStart->modify('+1 hour');
125        $durationSeconds = max(0, $origEnd->getTimestamp() - $origStart->getTimestamp());
126
127        $exceptions = $this->parseExceptions($master['recurrence_exceptions'] ?? null);
128        $instances = [];
129        $current = $origStart;
130        $count = 0;
131
132        while ($count < $maxOccurrences) {
133            if ($this->shouldStopRecurrence($current, $until, $maxCount, $count, $rangeEnd)) {
134                break;
135            }
136
137            $currentEnd = $current->modify("+{$durationSeconds} seconds");
138            if ($this->matchesOccurrence($current, $currentEnd, $byDay, $exceptions, $rangeStart, $rangeEnd)) {
139                $instances[] = $this->buildInstanceRecord($master, $current, $currentEnd, $count);
140            }
141
142            $count++;
143            $current = $this->advanceDate($current, $freq, $interval);
144        }
145
146        return $instances;
147    }
148
149    /**
150     * Checks if recurrence expansion should terminate.
151     */
152    private function shouldStopRecurrence(
153        DateTimeImmutable $current,
154        ?DateTimeImmutable $until,
155        ?int $maxCount,
156        int $count,
157        DateTimeInterface $rangeEnd
158    ): bool {
159        if ($until !== null && $current > $until) {
160            return true;
161        }
162        if ($maxCount !== null && $count >= $maxCount) {
163            return true;
164        }
165        return $current > $rangeEnd;
166    }
167
168    /**
169     * Checks if current occurrence matches day criteria, exceptions, and date range.
170     *
171     * @param list<string> $byDay
172     * @param list<string> $exceptions
173     */
174    private function matchesOccurrence(
175        DateTimeImmutable $current,
176        DateTimeImmutable $currentEnd,
177        array $byDay,
178        array $exceptions,
179        DateTimeInterface $rangeStart,
180        DateTimeInterface $rangeEnd
181    ): bool {
182        $dayCodeRfc = substr(strtoupper($current->format('D')), 0, 2);
183        $dayMatches = $byDay === [] || in_array($dayCodeRfc, $byDay, true);
184        $dateKey = $current->format('Y-m-d');
185
186        return $dayMatches
187            && !in_array($dateKey, $exceptions, true)
188            && $currentEnd >= $rangeStart
189            && $current <= $rangeEnd;
190    }
191
192    /**
193     * Builds expanded occurrence event record.
194     *
195     * @param array<string, mixed> $master Master record.
196     * @return array<string, mixed>
197     */
198    private function buildInstanceRecord(
199        array $master,
200        DateTimeImmutable $current,
201        DateTimeImmutable $currentEnd,
202        int $count
203    ): array {
204        $inst = $master;
205        $inst['id'] = $count === 0 ? (int) ($master['id'] ?? 0) : $master['id'] . '_' . $current->getTimestamp();
206        $inst['start_date'] = $current->format('Y-m-d H:i:s');
207        $inst['end_date'] = $currentEnd->format('Y-m-d H:i:s');
208        $inst['recurrence_parent_id'] = $master['id'] ?? null;
209        $inst['is_recurrence_instance'] = $count > 0;
210
211        return $inst;
212    }
213
214    /**
215     * Parses EXDATE exceptions from JSON or comma-separated string.
216     *
217     * @param mixed $raw Raw exceptions input.
218     * @return string[] Array of Y-m-d date strings.
219     */
220    public function parseExceptions(mixed $raw): array
221    {
222        if ($raw === null || $raw === '' || $raw === '[]') {
223            return [];
224        }
225
226        if (is_array($raw)) {
227            return array_values(array_map('strval', $raw));
228        }
229
230        $decoded = json_decode((string) $raw, true);
231        $items = is_array($decoded) ? $decoded : explode(',', (string) $raw);
232
233        return array_values(array_filter(array_map(
234            static fn($v): string => trim((string) $v),
235            $items
236        )));
237    }
238
239    /**
240     * Advances date according to recurrence frequency and interval.
241     *
242     * @param DateTimeImmutable $date Current date.
243     * @param string $freq Recurrence frequency.
244     * @param int $interval Interval multiplier.
245     * @return DateTimeImmutable Advanced date.
246     */
247    private function advanceDate(DateTimeImmutable $date, string $freq, int $interval): DateTimeImmutable
248    {
249        return match ($freq) {
250            'DAILY'   => $date->modify("+{$interval} days"),
251            'WEEKLY'  => $date->modify("+{$interval} weeks"),
252            'MONTHLY' => $date->modify("+{$interval} months"),
253            'YEARLY'  => $date->modify("+{$interval} years"),
254            default   => $date->modify("+{$interval} days"),
255        };
256    }
257}