diff --git a/Calendar.class.php b/Calendar.class.php index 3fe88c71..aa3b2426 100644 --- a/Calendar.class.php +++ b/Calendar.class.php @@ -648,11 +648,33 @@ public function sync($output, $force = false) $next = !empty($calendar['next']) ? $calendar['next'] : 300; if ($force || ($last + $next) < time()) { $calendar['id'] = $id; - $c = $cal->getDriverById($id); - $c->processCalendar($calendar); - $cal->setConfig($id, time(), 'calendar-sync'); - $output->writeln("Done"); - $this->FreePBX->Hooks->processHooks($calendar['id']); + try { + $c = $cal->getDriverById($id); + $c->processCalendar($calendar); + $cal->setConfig($id, time(), 'calendar-sync'); + $output->writeln("Done"); + $this->FreePBX->Hooks->processHooks($calendar['id']); + // Clear any previous failure notification for this calendar + \FreePBX::Notifications()->delete('calendar', 'SYNC_FAIL_' . $id); + } catch (\Throwable $e) { + $output->writeln("FAILED: " . $e->getMessage() . ""); + $calName = htmlspecialchars($calendar['name'], ENT_QUOTES, 'UTF-8'); + $errMsg = htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8'); + \FreePBX::Notifications()->add_critical( + 'calendar', + 'SYNC_FAIL_' . $id, + sprintf(_('Calendar sync failed: %s'), $calName), + sprintf( + _('The calendar "%s" failed to sync at %s. Time conditions and call routing that depend on this calendar are using stale data and may route calls incorrectly. Error: %s'), + $calName, + date('Y-m-d H:i:s T'), + $errMsg + ), + '', + false, + true + ); + } } else $output->writeln("Skipping"); } diff --git a/IcalParser/IcalRangedParser.php b/IcalParser/IcalRangedParser.php index 3ec5c804..eaf5a305 100644 --- a/IcalParser/IcalRangedParser.php +++ b/IcalParser/IcalRangedParser.php @@ -180,6 +180,21 @@ public function parseRecurrences($event): array break; //retrieval failed, probably we are out of period or the recurrence is invalid } + // Filter occurrences that have been replaced by RECURRENCE-ID + // exception events (moved/cancelled instances). The non-fast + // path does this at the end of parseRecurrences; the fast path + // must do it here because it returns early. + if (!empty($this->data['_RECURRENCE_IDS'])) { + $tz = $event['DTSTART']->getTimezone(); + $recurrences = array_values(array_filter($recurrences, function ($ts) use ($tz) { + $local = (new \DateTime('@' . $ts))->setTimezone($tz); + $utc = new \DateTime('@' . $ts, new \DateTimeZone('UTC')); + return empty($this->data['_RECURRENCE_IDS'][$local->format('Ymd')]) + && empty($this->data['_RECURRENCE_IDS'][$local->format('Ymd\THis')]) + && empty($this->data['_RECURRENCE_IDS'][$utc->format('Ymd\THis\Z')]); + })); + } + return $recurrences; } else { //setting end to the one defined above and generating a new Frequency object @@ -286,69 +301,23 @@ public function getEvents(): EventsList $event = $this->data['VEVENT'][$i]; if (empty($event['RECURRENCES'])) { - if (!empty($event['RECURRENCE-ID']) && !empty($event['UID']) && isset($event['SEQUENCE'])) { - /** - * You may ask why I abandoned all this code, I will try to explain as clearly as possible: - * - * First problem -> the recurring rule could have many formats, but as it is now only a specific one can be interpreted. This is not a big problem, we could generate a warning if we were unable to decode it. Here are some exmaples: - * RECURRENCE-ID;RANGE=THISANDFUTURE:19960120T120000Z -> Becomes 19960120T120000Z (not matched becasue of the final "Z" + RANGE ignored) - * RECURRENCE-ID;VALUE=DATE:19960401 -> Becomes 19960401 (not matched beacuse it is only a date) - * RECURRENCE-ID;RANGE=THISANDFUTURE:TZID=UTC:20230531T120000 -> Becomes TZID=UTC:20230531T120000 (not matched because of the extra TZID) - * RECURRENCE-ID;TZID=UTC:20230531T120000 -> Becomes 20230531T120000 (matched) - * RECURRENCE-ID;VALUE=DATE-TIME:20210115T100000 -> Becomes 20230531T120000 (matched) - * - * Second problem -> If we were able to overcome the first point and we get a match with the exact same DTSTART and an increased sequence (look at the first "if" below) the event is replaced entirely. Fine you'll say but there is a catch, recurrences are calculated on the original event and not updated causing all sort of problems (beside that, why don't you update the original event directly instead of using this quirks?) - * - * Third problem -> If we instead fall into the second "if" we will search for recurrences to replace inside the original event. Perfect! But there is a catch! You see as soon as we find a recurrence that match we delete it and... nothing more! So the user is expecting this particular recurrence to be replaced by the new event but instead will find that recurrence completely deleted! - * - * So all in all fixing all this is issues is too complicated, if someone wants to do it free to do so, but be careful of the speed too... - */ - - /* - $modifiedEventUID = $event['UID']; - $modifiedEventRecurID = $event['RECURRENCE-ID']; - $modifiedEventSeq = intval($event['SEQUENCE'], 10); - - if (isset($this->data["_RECURRENCE_COUNTERS_BY_UID"][$modifiedEventUID])) { - $counter = $this->data["_RECURRENCE_COUNTERS_BY_UID"][$modifiedEventUID]; - - $originalEvent = $this->data["VEVENT"][$counter]; - if (isset($originalEvent['SEQUENCE'])) { - $originalEventSeq = intval($originalEvent['SEQUENCE'], 10); - $originalEventFormattedStartDate = $originalEvent['DTSTART']->format('Ymd\THis'); - if ($modifiedEventRecurID === $originalEventFormattedStartDate && $modifiedEventSeq > $originalEventSeq) { - // this modifies the original event - $modifiedEvent = array_replace_recursive($originalEvent, $event); - $this->data["VEVENT"][$counter] = $modifiedEvent; - foreach ($events as $z => $event) { - if ($events[$z]['UID'] === $originalEvent['UID'] && - $events[$z]['SEQUENCE'] === $originalEvent['SEQUENCE']) { - // replace the original event with the modified event - $events[$z] = $modifiedEvent; - break; - } - } - $event = null; // don't add this to the $events[] array again - } else if (!empty($originalEvent['RECURRENCES'])) { - for ($j = 0; $j < count($originalEvent['RECURRENCES']); $j++) { - $recurDate = $originalEvent['RECURRENCES'][$j]; - $formattedStartDate = $recurDate->format('Ymd\THis'); - if ($formattedStartDate === $modifiedEventRecurID) { - unset($this->data["VEVENT"][$counter]['RECURRENCES'][$j]); - $this->data["VEVENT"][$counter]['RECURRENCES'] = array_values($this->data["VEVENT"][$counter]['RECURRENCES']); - break; - } - } - } - } - } - */ + if (!empty($event['RECURRENCE-ID']) && !empty($event['UID'])) { + // RECURRENCE-ID exception: this VEVENT replaces or cancels + // a single occurrence of a recurring series. The original + // occurrence was already removed during parseRecurrences() + // (which filters timestamps matching _RECURRENCE_IDS). + // We include the replacement event if it is not cancelled + // and falls within the calendar range — no need to touch + // the parent series' recurrence list. + if (strtoupper($event['STATUS'] ?? '') === 'CANCELLED') { + $event = null; + } + } - \FreePBX::Notifications()->add_warning('calendar', 'RECURRENCEID', _('Calendar using RECURRENCE-ID'), str_replace('%event', $event['SUMMARY'], _('A calendar you have added has an event called "%event" that has a RECURRENCE-ID rule. Because there is no full support yet they are ignored, please consider changing this to Exceptions/Additions.')), "", true, true); - $event = null; //this is an event that modifies another one. In every case (even if we weren't able to overwrite the original one for whatever reason) it should not end up in the output - } else { - //neither start nor end is within range so skip it - if(isset($event['DTSTART']) && isset($event['DTEND'])) { + // Range check for all non-recurring events (regular + + // RECURRENCE-ID replacements that survived the cancel check). + if (!empty($event)) { + if (isset($event['DTSTART']) && isset($event['DTEND'])) { if (!$this->eventRangeInCalendarRange($event['DTSTART'], $event['DTEND'])) { $event = null; } @@ -426,11 +395,15 @@ public function getEventsNow($now = 0) $event = $this->data['VEVENT'][$i]; if (empty($event['RECURRENCES'])) { - if (!empty($event['RECURRENCE-ID']) && !empty($event['UID']) && isset($event['SEQUENCE'])) { - \FreePBX::Notifications()->add_warning('calendar', 'RECURRENCEID', _('Calendar using RECURRENCE-ID'), str_replace('%event', $event['SUMMARY'], _('A calendar you have added has an event called "%event" that has a RECURRENCE-ID rule. Because there is no full support yet they are ignored, please consider changing this to Exceptions/Additions.')), "", true, true); - } else { - if ($event['DTSTART']->getTimestamp() < $now && $now < $event['DTEND']->getTimestamp()) - array_push($events, $event); //event is now, keep it + // Skip cancelled RECURRENCE-ID exceptions (deleted instances) + if (!empty($event['RECURRENCE-ID']) && strtoupper($event['STATUS'] ?? '') === 'CANCELLED') { + continue; + } + + // Include single events and RECURRENCE-ID replacements if happening now + if (isset($event['DTSTART']) && isset($event['DTEND']) + && $event['DTSTART']->getTimestamp() < $now && $now < $event['DTEND']->getTimestamp()) { + array_push($events, $event); } } else { $recurrences = $event['RECURRENCES']; diff --git a/drivers/Base.php b/drivers/Base.php index 144b95f6..a57a9d0b 100644 --- a/drivers/Base.php +++ b/drivers/Base.php @@ -202,14 +202,36 @@ public function buildCache() $end->add(new \DateInterval(self::ADD_END)); $icalData = $this->getIcal(); if ($icalData) { - $cal = new IcalRangedParser(true); - $cal->setStartRange($start); - $cal->setEndRange($end); - $raw = $cal->parseString($icalData); - $this->calendarClass->setConfig($this->calendar['id'], serialize($raw), 'calendar-cache'); - $this->calendarClass->setConfig($this->calendar['id'], $start->getTimestamp(), 'calendar-cache_valid_notbefore'); - $this->calendarClass->setConfig($this->calendar['id'], $end->getTimestamp(), 'calendar-cache_valid_notafter'); - return true; + try { + $cal = new IcalRangedParser(true); + $cal->setStartRange($start); + $cal->setEndRange($end); + $raw = $cal->parseString($icalData); + $this->calendarClass->setConfig($this->calendar['id'], serialize($raw), 'calendar-cache'); + $this->calendarClass->setConfig($this->calendar['id'], $start->getTimestamp(), 'calendar-cache_valid_notbefore'); + $this->calendarClass->setConfig($this->calendar['id'], $end->getTimestamp(), 'calendar-cache_valid_notafter'); + return true; + } catch (\Throwable $e) { + if (class_exists('FreePBX')) { + $calName = htmlspecialchars($this->calendar['name'] ?? $this->calendar['id'], ENT_QUOTES, 'UTF-8'); + $errMsg = htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8'); + \FreePBX::Notifications()->add_critical( + 'calendar', + 'PARSE_FAIL_' . $this->calendar['id'], + sprintf(_('Calendar parse failed: %s'), $calName), + sprintf( + _('The calendar "%s" could not be parsed at %s. This calendar\'s events will not be evaluated for time conditions or call routing until the problem is resolved. Error: %s'), + $calName, + date('Y-m-d H:i:s T'), + $errMsg + ), + '', + false, + true + ); + } + return false; + } } else { return false; } diff --git a/utests/IcalRangedParserTest.php b/utests/IcalRangedParserTest.php index 8d831f9f..b320758d 100644 --- a/utests/IcalRangedParserTest.php +++ b/utests/IcalRangedParserTest.php @@ -307,6 +307,142 @@ function testFREEPBX18919() { $this->assertEvents($assertEvents, $events); } + /** + * Test RECURRENCE-ID exception handling: + * - Jan 26 occurrence moved to 10:00-18:00 (should appear at new time) + * - Feb 2 occurrence cancelled (should not appear) + * - Other Mondays should appear at the original 09:00-17:00 + */ + function testRecurrenceIdExceptions() { + $raw = file_get_contents(__DIR__.'/icals/recurrence-id-exceptions.ics'); + $cal = new IcalRangedParser(); + $cal->setStartRange(Carbon::parse("2026-01-12")); + $cal->setEndRange(Carbon::parse("2026-02-09")); + $cal->parseString($raw); + $events = $cal->getSortedEvents(); + + $this->assertNotEmpty($events, 'No events could be found'); + + // Collect event summaries and start dates for easier assertion + $parsed = []; + foreach ($events as $event) { + $parsed[] = [ + 'date' => Carbon::instance($event['DTSTART'])->format('Y-m-d'), + 'time' => Carbon::instance($event['DTSTART'])->format('H:i'), + 'summary' => $event['SUMMARY'], + 'cancelled' => (strtoupper($event['STATUS'] ?? '') === 'CANCELLED'), + ]; + } + + // Build a lookup by date + $byDate = []; + foreach ($parsed as $p) { + $byDate[$p['date']] = $p; + } + + // Jan 12 — original occurrence, should exist at 09:00 + $this->assertArrayHasKey('2026-01-12', $byDate, 'Jan 12 occurrence missing'); + $this->assertEquals('09:00', $byDate['2026-01-12']['time']); + + // Jan 19 — original occurrence, should exist at 09:00 + $this->assertArrayHasKey('2026-01-19', $byDate, 'Jan 19 occurrence missing'); + $this->assertEquals('09:00', $byDate['2026-01-19']['time']); + + // Jan 26 — moved to 10:00, should appear at 10:00 with modified summary + $this->assertArrayHasKey('2026-01-26', $byDate, 'Jan 26 modified occurrence missing'); + $this->assertEquals('10:00', $byDate['2026-01-26']['time']); + $this->assertStringContainsString('moved to 10am', $byDate['2026-01-26']['summary']); + + // Feb 2 — cancelled, should NOT appear + $this->assertArrayNotHasKey('2026-02-02', $byDate, 'Feb 2 cancelled occurrence should not appear'); + + // Feb 9 — original occurrence, should exist at 09:00 + $this->assertArrayHasKey('2026-02-09', $byDate, 'Feb 9 occurrence missing'); + $this->assertEquals('09:00', $byDate['2026-02-09']['time']); + } + + /** + * Test RECURRENCE-ID with all-day events (VALUE=DATE format). + */ + function testRecurrenceIdAllDay() { + $raw = file_get_contents(__DIR__.'/icals/recurrence-id-allday.ics'); + $cal = new IcalRangedParser(); + $cal->setStartRange(Carbon::parse("2026-01-05")); + $cal->setEndRange(Carbon::parse("2026-02-02")); + $cal->parseString($raw); + $events = $cal->getSortedEvents(); + + $this->assertNotEmpty($events, 'No events could be found'); + + $dates = []; + $summaries = []; + foreach ($events as $event) { + $d = Carbon::instance($event['DTSTART'])->format('Y-m-d'); + $dates[] = $d; + $summaries[$d] = $event['SUMMARY']; + } + + // Jan 5 — original + $this->assertContains('2026-01-05', $dates, 'Jan 5 occurrence missing'); + + // Jan 12 — original + $this->assertContains('2026-01-12', $dates, 'Jan 12 occurrence missing'); + + // Jan 19 — modified title (should appear) + $this->assertContains('2026-01-19', $dates, 'Jan 19 modified occurrence missing'); + $this->assertStringContainsString('modified title', $summaries['2026-01-19']); + + // Jan 26 — cancelled (should NOT appear) + $this->assertNotContains('2026-01-26', $dates, 'Jan 26 cancelled occurrence should not appear'); + } + + /** + * Test RECURRENCE-ID handling in fast mode (getEventsNow). + * Verifies that: + * - A modified occurrence is matched at its new time + * - A cancelled occurrence is not matched + * - Original (non-excepted) occurrences still match at original time + */ + function testRecurrenceIdFastMode() { + $raw = file_get_contents(__DIR__.'/icals/recurrence-id-exceptions.ics'); + + // Check that Jan 26 at 14:00 EST matches (moved occurrence: 10:00-18:00) + $cal = new IcalRangedParser(true); + $jan26_2pm = Carbon::parse("2026-01-26 14:00:00", "America/New_York"); + $cal->setStartRange($jan26_2pm->copy()->subDay()); + $cal->setEndRange($jan26_2pm->copy()->addDay()); + $cal->parseString($raw); + $events = $cal->getEventsNow($jan26_2pm->getTimestamp()); + $this->assertNotEmpty($events, 'Jan 26 2pm should match the moved occurrence'); + + // Check that Jan 26 at 08:30 EST does NOT match (old time, occurrence was moved) + $cal2 = new IcalRangedParser(true); + $jan26_830 = Carbon::parse("2026-01-26 08:30:00", "America/New_York"); + $cal2->setStartRange($jan26_830->copy()->subDay()); + $cal2->setEndRange($jan26_830->copy()->addDay()); + $cal2->parseString($raw); + $events2 = $cal2->getEventsNow($jan26_830->getTimestamp()); + $this->assertFalse($events2, 'Jan 26 8:30am should NOT match (occurrence was moved to 10am)'); + + // Check that Feb 2 at 12:00 EST does NOT match (cancelled) + $cal3 = new IcalRangedParser(true); + $feb2_noon = Carbon::parse("2026-02-02 12:00:00", "America/New_York"); + $cal3->setStartRange($feb2_noon->copy()->subDay()); + $cal3->setEndRange($feb2_noon->copy()->addDay()); + $cal3->parseString($raw); + $events3 = $cal3->getEventsNow($feb2_noon->getTimestamp()); + $this->assertFalse($events3, 'Feb 2 noon should NOT match (occurrence was cancelled)'); + + // Check that Jan 19 at 12:00 EST still matches (unmodified occurrence) + $cal4 = new IcalRangedParser(true); + $jan19_noon = Carbon::parse("2026-01-19 12:00:00", "America/New_York"); + $cal4->setStartRange($jan19_noon->copy()->subDay()); + $cal4->setEndRange($jan19_noon->copy()->addDay()); + $cal4->parseString($raw); + $events4 = $cal4->getEventsNow($jan19_noon->getTimestamp()); + $this->assertNotEmpty($events4, 'Jan 19 noon should match (unmodified occurrence)'); + } + function assertEvents($assertEvents, $events) { $this->assertEquals(is_countable($events) ? count($events) : 0, is_countable($assertEvents) ? count($assertEvents) : 0); foreach($assertEvents as $k => $args) { diff --git a/utests/icals/recurrence-id-allday.ics b/utests/icals/recurrence-id-allday.ics new file mode 100644 index 00000000..7cd28397 --- /dev/null +++ b/utests/icals/recurrence-id-allday.ics @@ -0,0 +1,30 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//ICCI LLC//FreePBX Calendar Test//EN +X-WR-TIMEZONE:America/New_York +BEGIN:VEVENT +DTSTART;VALUE=DATE:20260105 +DTEND;VALUE=DATE:20260106 +RRULE:FREQ=WEEKLY;BYDAY=MO +SUMMARY:All-Day Monday +UID:test-recurrence-id-allday@icci.com +SEQUENCE:0 +END:VEVENT +BEGIN:VEVENT +DTSTART;VALUE=DATE:20260119 +DTEND;VALUE=DATE:20260120 +RECURRENCE-ID;VALUE=DATE:20260119 +SUMMARY:All-Day Monday (modified title) +UID:test-recurrence-id-allday@icci.com +SEQUENCE:1 +END:VEVENT +BEGIN:VEVENT +DTSTART;VALUE=DATE:20260126 +DTEND;VALUE=DATE:20260127 +RECURRENCE-ID;VALUE=DATE:20260126 +SUMMARY:All-Day Monday +UID:test-recurrence-id-allday@icci.com +SEQUENCE:1 +STATUS:CANCELLED +END:VEVENT +END:VCALENDAR diff --git a/utests/icals/recurrence-id-exceptions.ics b/utests/icals/recurrence-id-exceptions.ics new file mode 100644 index 00000000..d8999daf --- /dev/null +++ b/utests/icals/recurrence-id-exceptions.ics @@ -0,0 +1,47 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//ICCI LLC//FreePBX Calendar Test//EN +X-WR-TIMEZONE:America/New_York +BEGIN:VTIMEZONE +TZID:America/New_York +BEGIN:STANDARD +DTSTART:19701101T020000 +RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +TZNAME:EST +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19700308T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +TZNAME:EDT +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VEVENT +DTSTART;TZID=America/New_York:20260112T090000 +DTEND;TZID=America/New_York:20260112T170000 +RRULE:FREQ=WEEKLY;BYDAY=MO +SUMMARY:Weekly Monday Meeting +UID:test-recurrence-id-001@icci.com +SEQUENCE:0 +END:VEVENT +BEGIN:VEVENT +DTSTART;TZID=America/New_York:20260126T100000 +DTEND;TZID=America/New_York:20260126T180000 +RECURRENCE-ID;TZID=America/New_York:20260126T090000 +SUMMARY:Weekly Monday Meeting (moved to 10am) +UID:test-recurrence-id-001@icci.com +SEQUENCE:1 +END:VEVENT +BEGIN:VEVENT +DTSTART;TZID=America/New_York:20260202T090000 +DTEND;TZID=America/New_York:20260202T170000 +RECURRENCE-ID;TZID=America/New_York:20260202T090000 +SUMMARY:Weekly Monday Meeting +UID:test-recurrence-id-001@icci.com +SEQUENCE:1 +STATUS:CANCELLED +END:VEVENT +END:VCALENDAR