From 37e36182e9c8facda41aea064f884d03df4d2daa Mon Sep 17 00:00:00 2001 From: Alex Tselegidis Date: Mon, 25 Nov 2024 11:59:13 +0100 Subject: [PATCH] Improve the CalDAV syncing mechanism so that it connects to more systems without problems (#1622) --- CHANGELOG.md | 1 + application/controllers/Caldav.php | 16 +- application/libraries/Caldav_sync.php | 174 +++++++++++++++++++--- application/models/Appointments_model.php | 18 +++ assets/js/utils/calendar_default_view.js | 2 +- assets/js/utils/calendar_sync.js | 13 ++ 6 files changed, 193 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85ddd47759..1e3c661654 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ developers to maintain and readjust their custom modifications on the main proje - Fix the date parsing issue on Safari web browsers during the booking process (#1584) - Fix working plan configuration am/pm hour parsing so that it works in all languages (#1606) +- Improve the CalDAV syncing mechanism so that it connects to more systems without problems (#1622) ## [1.5.0] - 2024-07-07 diff --git a/application/controllers/Caldav.php b/application/controllers/Caldav.php index 9c422cf28d..6363450c41 100644 --- a/application/controllers/Caldav.php +++ b/application/controllers/Caldav.php @@ -150,6 +150,10 @@ public static function sync(string $provider_id): void // Sync each appointment with CalDAV Calendar by following the project's sync protocol (see documentation). foreach ($local_events as $local_event) { + if (str_contains($local_event['id_caldav_calendar'], 'RECURRENCE')) { + continue; + } + if (!$local_event['is_unavailability']) { $service = $CI->services_model->find($local_event['id_services']); $customer = $CI->customers_model->find($local_event['id_users_customer']); @@ -160,8 +164,6 @@ public static function sync(string $provider_id): void $events_model = $CI->unavailabilities_model; } - // If current appointment not synced yet, add to CalDAV Calendar. - if (!$local_event['id_caldav_calendar']) { if (!$local_event['is_unavailability']) { $caldav_event_id = $CI->caldav_sync->save_appointment($local_event, $service, $provider, $customer); @@ -192,19 +194,15 @@ public static function sync(string $provider_id): void $caldav_event_start = new DateTime($caldav_event['start_datetime']); $caldav_event_end = new DateTime($caldav_event['end_datetime']); - $caldav_event_notes = $local_event['is_unavailability'] - ? $caldav_event['summary'] . ' ' . $caldav_event['description'] - : $caldav_event['description']; - $is_different = $local_event_start !== $caldav_event_start->getTimestamp() || $local_event_end !== $caldav_event_end->getTimestamp() || - $local_event['notes'] !== $caldav_event_notes; + $local_event['notes'] !== $caldav_event['description']; if ($is_different) { $local_event['start_datetime'] = $caldav_event_start->format('Y-m-d H:i:s'); $local_event['end_datetime'] = $caldav_event_end->format('Y-m-d H:i:s'); - $local_event['notes'] = $caldav_event_notes; + $local_event['notes'] = $caldav_event['description']; $events_model->save($local_event); } } catch (Throwable) { @@ -229,6 +227,8 @@ public static function sync(string $provider_id): void } } + $CI->appointments_model->delete_caldav_recurring_events($start_date_time, $end_date_time); + foreach ($caldav_events as $caldav_event) { if ($caldav_event['status'] === 'CANCELLED') { continue; diff --git a/application/libraries/Caldav_sync.php b/application/libraries/Caldav_sync.php index 5283f9134f..2e038b2cfe 100644 --- a/application/libraries/Caldav_sync.php +++ b/application/libraries/Caldav_sync.php @@ -14,6 +14,7 @@ use GuzzleHttp\Client; use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Exception\RequestException; +use Jsvrcek\ICS\Exception\CalendarEventException; use Psr\Http\Message\ResponseInterface; use Sabre\VObject\Component\VEvent; use Sabre\VObject\Reader; @@ -37,6 +38,8 @@ class Caldav_sync * * This method initializes the Caldav client class and the Calendar service class so that they can be used by the * other methods. + * + * @throws Exception If there is an issue with the initialization. */ public function __construct() { @@ -60,7 +63,7 @@ public function __construct() * * @return string|null Returns the event ID * - * @throws \Jsvrcek\ICS\Exception\CalendarEventException + * @throws CalendarEventException If there's an issue generating the ICS file. */ public function save_appointment(array $appointment, array $service, array $provider, array $customer): ?string { @@ -96,11 +99,16 @@ public function save_appointment(array $appointment, array $service, array $prov * * @return string|null Returns the event ID * - * @throws \Jsvrcek\ICS\Exception\CalendarEventException + * @throws CalendarEventException If there's an issue generating the ICS file. */ public function save_unavailability(array $unavailability, array $provider): ?string { try { + // If the unavailability is reccuring don't sync + if (strpos($unavailability['id_caldav_calendar'], 'RECURRENCE') !== false) { + return $unavailability['id_caldav_calendar']; + } + $ics_file = $this->get_unavailability_ics_file($unavailability, $provider); $client = $this->get_http_client_by_provider_id($provider['id']); @@ -150,7 +158,7 @@ public function delete_event(array $provider, string $caldav_event_id): void * @param string $caldav_event_id CalDAV calendar event ID. * * @return array|null - * @throws Exception + * @throws Exception If there’s an issue parsing the ICS data. */ public function get_event(array $provider, string $caldav_event_id): ?array { @@ -182,40 +190,140 @@ public function get_event(array $provider, string $caldav_event_id): ?array * @param string $end_date_time The end date of sync period. * * @return array - * @throws Exception + * @throws Exception If there's an issue with event fetching or parsing. */ public function get_sync_events(array $provider, string $start_date_time, string $end_date_time): array { try { $client = $this->get_http_client_by_provider_id($provider['id']); - $provider_timezone_object = new DateTimeZone($provider['timezone']); $response = $this->fetch_events($client, $start_date_time, $end_date_time); + if (!$response->getBody()) { + log_message('error', 'No response body from fetch_events' . PHP_EOL); + return []; + } + $xml = new SimpleXMLElement($response->getBody(), 0, false, 'd', true); - $events = []; + if ($xml->children('d', true)) { + return $this->parse_xml_events($xml, $start_date_time, $end_date_time, $provider_timezone_object); + } + + $ics_file_urls = $this->extract_ics_file_urls($response->getBody()); + return $this->fetch_and_parse_ics_files( + $client, + $ics_file_urls, + $start_date_time, + $end_date_time, + $provider_timezone_object, + ); + } catch (GuzzleException $e) { + $this->handle_guzzle_exception($e, 'Failed to save CalDAV event'); + return []; + } + } - foreach ($xml->children('d', true) as $response) { - $ics_file = (string) $response->propstat->prop->children('cal', true); + private function parse_xml_events( + SimpleXMLElement $xml, + string $start_date_time, + string $end_date_time, + DateTimeZone $timezone, + ): array { + $events = []; + + foreach ($xml->children('d', true) as $response) { + $ics_contents = (string) $response->propstat->prop->children('cal', true); + + $events = array_merge( + $events, + $this->expand_ics_content($ics_contents, $start_date_time, $end_date_time, $timezone), + ); + } - $vcalendar = Reader::read($ics_file); + return $events; + } - $expanded_vcalendar = $vcalendar->expand(new DateTime($start_date_time), new DateTime($end_date_time)); + private function extract_ics_file_urls(string $body): array + { + $ics_files = []; + $lines = explode("\n", $body); + foreach ($lines as $line) { + if (preg_match('/\/calendars\/.*?\.ics/', $line, $matches)) { + $ics_files[] = $matches[0]; + } + } + return $ics_files; + } - foreach ($expanded_vcalendar->VEVENT as $event) { - $events[] = $this->convert_caldav_event_to_array_event($event, $provider_timezone_object); + /** + * Fetch and parse the ICS files from the remote server + * + * @param Client $client + * @param array $ics_file_urls + * @param string $start_date_time + * @param string $end_date_time + * @param DateTimeZone $timezone_OBJECT + * + * @return array + */ + private function fetch_and_parse_ics_files( + Client $client, + array $ics_file_urls, + string $start_date_time, + string $end_date_time, + DateTimeZone $timezone_OBJECT, + ): array { + $events = []; + + foreach ($ics_file_urls as $ics_file_url) { + try { + $ics_response = $client->request('GET', $ics_file_url); + + $ics_contents = $ics_response->getBody()->getContents(); + + if (empty($ics_contents)) { + log_message('error', 'ICS file data is empty for URL: ' . $ics_file_url . PHP_EOL); + continue; } - // $events[] = $this->convert_caldav_event_to_array_event($vcalendar->VEVENT, $provider_timezone_object); + $events = array_merge( + $events, + $this->expand_ics_content($ics_contents, $start_date_time, $end_date_time, $timezone_OBJECT), + ); + } catch (GuzzleException $e) { + log_message( + 'error', + 'Failed to fetch ICS content from ' . $ics_file_url . ': ' . $e->getMessage() . PHP_EOL, + ); } + } - return $events; - } catch (GuzzleException $e) { - $this->handle_guzzle_exception($e, 'Failed to save CalDAV event'); - return []; + return $events; + } + + private function expand_ics_content( + string $ics_contents, + string $start_date_time, + string $end_date_time, + DateTimeZone $timezone_object, + ): array { + $events = []; + + try { + $vcalendar = Reader::read($ics_contents); + + $expanded_vcalendar = $vcalendar->expand(new DateTime($start_date_time), new DateTime($end_date_time)); + + foreach ($expanded_vcalendar->VEVENT as $event) { + $events[] = $this->convert_caldav_event_to_array_event($event, $timezone_object); + } + } catch (Throwable $e) { + log_message('error', 'Failed to parse or expand calendar data: ' . $e->getMessage() . PHP_EOL); } + + return $events; } /** @@ -247,6 +355,10 @@ private function handle_guzzle_exception(GuzzleException $e, string $message): v log_message('error', $message . ' ' . $guzzle_info); } + /** + * @throws Exception If there is an invalid CalDAV URL or credentials. + * @throws GuzzleException If there’s an issue with the HTTP request. + */ private function get_http_client(string $caldav_url, string $caldav_username, string $caldav_password): Client { if (!filter_var($caldav_url, FILTER_VALIDATE_URL)) { @@ -254,11 +366,11 @@ private function get_http_client(string $caldav_url, string $caldav_username, st } if (!$caldav_username) { - throw new InvalidArgumentException('Invalid CalDAV username provided: ' . $caldav_username); + throw new InvalidArgumentException('Missing CalDAV username'); } if (!$caldav_password) { - throw new InvalidArgumentException('Invalid CalDAV password provided: ' . $caldav_password); + throw new InvalidArgumentException('Missing CalDAV password'); } return new Client([ @@ -320,7 +432,7 @@ private function get_caldav_event_uri(string $caldav_calendar, ?string $caldav_e } /** - * @throws \Jsvrcek\ICS\Exception\CalendarEventException + * @throws CalendarEventException */ private function get_appointment_ics_file( array $appointment, @@ -334,7 +446,7 @@ private function get_appointment_ics_file( } /** - * @throws \Jsvrcek\ICS\Exception\CalendarEventException + * @throws CalendarEventException */ private function get_unavailability_ics_file(array $unavailability, array $provider): string { @@ -365,8 +477,26 @@ private function convert_caldav_event_to_array_event(VEvent $vevent, DateTimeZon $end_date_time_object = new DateTime((string) $vevent->DTEND, $utc_timezone_object); $end_date_time_object->setTimezone($timezone_object); + // Check if the event is recurring + + $is_recurring_event = + isset($vevent->RRULE) || + isset($vevent->RDATE) || + isset($vevent->{'RECURRENCE-ID'}) || + isset($vevent->EXDATE); + + // Generate ID based on recurrence status + + $event_id = (string) $vevent->UID; + + if ($is_recurring_event) { + $event_id .= '-RECURRENCE-' . random_string(); + } + + // Return the converted event + return [ - 'id' => ((string) $vevent->UID) . '-' . random_string(), + 'id' => $event_id, 'summary' => (string) $vevent->SUMMARY, 'start_datetime' => $start_date_time_object->format('Y-m-d H:i:s'), 'end_datetime' => $end_date_time_object->format('Y-m-d H:i:s'), diff --git a/application/models/Appointments_model.php b/application/models/Appointments_model.php index 6c4644c14e..7064fc6569 100644 --- a/application/models/Appointments_model.php +++ b/application/models/Appointments_model.php @@ -339,6 +339,24 @@ public function clear_caldav_sync_ids(int $provider_id): void $this->db->update('appointments', ['id_caldav_calendar' => null], ['id_users_provider' => $provider_id]); } + /** + * Deletes recurring CalDAV events for the provided date period. + * + * @param string $start_date_time + * @param string $end_date_time + * + * @return void + */ + public function delete_caldav_recurring_events(string $start_date_time, string $end_date_time): void + { + $this + ->db + ->where('start_datetime >=', $start_date_time) + ->where('end_datetime <=', $end_date_time) + ->like('id_caldav_calendar', '%RECURRENCE%') + ->delete('appointments'); + } + /** * Get the attendants number for the requested period. * diff --git a/assets/js/utils/calendar_default_view.js b/assets/js/utils/calendar_default_view.js index c0a03034f3..cc5e3ea94a 100755 --- a/assets/js/utils/calendar_default_view.js +++ b/assets/js/utils/calendar_default_view.js @@ -1645,7 +1645,7 @@ App.Utils.CalendarDefaultView = (function () { // Automatically refresh the calendar page every 10 seconds (without loading animation). setInterval(() => { - if ($('.popover').length) { + if ($('.popover').length || App.Utils.CalendarSync.isCurrentlySyncing()) { return; } diff --git a/assets/js/utils/calendar_sync.js b/assets/js/utils/calendar_sync.js index a21c26b1b4..acab80f7f6 100644 --- a/assets/js/utils/calendar_sync.js +++ b/assets/js/utils/calendar_sync.js @@ -25,6 +25,7 @@ App.Utils.CalendarSync = (function () { const $reloadAppointments = $('#reload-appointments'); const FILTER_TYPE_PROVIDER = 'provider'; + let isSyncing = false; function hasSync(type) { const $selectedOption = $selectFilterItem.find('option:selected'); @@ -168,6 +169,9 @@ App.Utils.CalendarSync = (function () { }) .fail(() => { App.Layouts.Backend.displayNotification(lang('calendar_sync_failed')); + }) + .always(() => { + isSyncing = false; }); } @@ -321,6 +325,9 @@ App.Utils.CalendarSync = (function () { }) .fail(() => { App.Layouts.Backend.displayNotification(lang('calendar_sync_failed')); + }) + .always(() => { + isSyncing = false; }); } @@ -372,6 +379,7 @@ App.Utils.CalendarSync = (function () { function onTriggerSyncClick() { const hasGoogleSync = hasSync('google'); + isSyncing = true; if (hasGoogleSync) { triggerGoogleSync(); @@ -385,6 +393,10 @@ App.Utils.CalendarSync = (function () { } } + function isCurrentlySyncing() { + return isSyncing; + } + /** * Initialize the module. */ @@ -399,5 +411,6 @@ App.Utils.CalendarSync = (function () { return { initialize, + isCurrentlySyncing, }; })();