Improve the CalDAV syncing mechanism so that it connects to more systems without problems (#1622)

This commit is contained in:
Alex Tselegidis 2024-11-25 11:59:13 +01:00
parent 947bd79821
commit 37e36182e9
6 changed files with 198 additions and 36 deletions

View file

@ -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 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) - 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 ## [1.5.0] - 2024-07-07

View file

@ -150,6 +150,10 @@ class Caldav extends EA_Controller
// Sync each appointment with CalDAV Calendar by following the project's sync protocol (see documentation). // Sync each appointment with CalDAV Calendar by following the project's sync protocol (see documentation).
foreach ($local_events as $local_event) { foreach ($local_events as $local_event) {
if (str_contains($local_event['id_caldav_calendar'], 'RECURRENCE')) {
continue;
}
if (!$local_event['is_unavailability']) { if (!$local_event['is_unavailability']) {
$service = $CI->services_model->find($local_event['id_services']); $service = $CI->services_model->find($local_event['id_services']);
$customer = $CI->customers_model->find($local_event['id_users_customer']); $customer = $CI->customers_model->find($local_event['id_users_customer']);
@ -160,8 +164,6 @@ class Caldav extends EA_Controller
$events_model = $CI->unavailabilities_model; $events_model = $CI->unavailabilities_model;
} }
// If current appointment not synced yet, add to CalDAV Calendar.
if (!$local_event['id_caldav_calendar']) { if (!$local_event['id_caldav_calendar']) {
if (!$local_event['is_unavailability']) { if (!$local_event['is_unavailability']) {
$caldav_event_id = $CI->caldav_sync->save_appointment($local_event, $service, $provider, $customer); $caldav_event_id = $CI->caldav_sync->save_appointment($local_event, $service, $provider, $customer);
@ -192,19 +194,15 @@ class Caldav extends EA_Controller
$caldav_event_start = new DateTime($caldav_event['start_datetime']); $caldav_event_start = new DateTime($caldav_event['start_datetime']);
$caldav_event_end = new DateTime($caldav_event['end_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 = $is_different =
$local_event_start !== $caldav_event_start->getTimestamp() || $local_event_start !== $caldav_event_start->getTimestamp() ||
$local_event_end !== $caldav_event_end->getTimestamp() || $local_event_end !== $caldav_event_end->getTimestamp() ||
$local_event['notes'] !== $caldav_event_notes; $local_event['notes'] !== $caldav_event['description'];
if ($is_different) { if ($is_different) {
$local_event['start_datetime'] = $caldav_event_start->format('Y-m-d H:i:s'); $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['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); $events_model->save($local_event);
} }
} catch (Throwable) { } catch (Throwable) {
@ -229,6 +227,8 @@ class Caldav extends EA_Controller
} }
} }
$CI->appointments_model->delete_caldav_recurring_events($start_date_time, $end_date_time);
foreach ($caldav_events as $caldav_event) { foreach ($caldav_events as $caldav_event) {
if ($caldav_event['status'] === 'CANCELLED') { if ($caldav_event['status'] === 'CANCELLED') {
continue; continue;

View file

@ -14,6 +14,7 @@
use GuzzleHttp\Client; use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\RequestException; use GuzzleHttp\Exception\RequestException;
use Jsvrcek\ICS\Exception\CalendarEventException;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Sabre\VObject\Component\VEvent; use Sabre\VObject\Component\VEvent;
use Sabre\VObject\Reader; 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 * This method initializes the Caldav client class and the Calendar service class so that they can be used by the
* other methods. * other methods.
*
* @throws Exception If there is an issue with the initialization.
*/ */
public function __construct() public function __construct()
{ {
@ -60,7 +63,7 @@ class Caldav_sync
* *
* @return string|null Returns the event ID * @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 public function save_appointment(array $appointment, array $service, array $provider, array $customer): ?string
{ {
@ -96,11 +99,16 @@ class Caldav_sync
* *
* @return string|null Returns the event ID * @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 public function save_unavailability(array $unavailability, array $provider): ?string
{ {
try { 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); $ics_file = $this->get_unavailability_ics_file($unavailability, $provider);
$client = $this->get_http_client_by_provider_id($provider['id']); $client = $this->get_http_client_by_provider_id($provider['id']);
@ -150,7 +158,7 @@ class Caldav_sync
* @param string $caldav_event_id CalDAV calendar event ID. * @param string $caldav_event_id CalDAV calendar event ID.
* *
* @return array|null * @return array|null
* @throws Exception * @throws Exception If theres an issue parsing the ICS data.
*/ */
public function get_event(array $provider, string $caldav_event_id): ?array public function get_event(array $provider, string $caldav_event_id): ?array
{ {
@ -182,42 +190,142 @@ class Caldav_sync
* @param string $end_date_time The end date of sync period. * @param string $end_date_time The end date of sync period.
* *
* @return array * @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 public function get_sync_events(array $provider, string $start_date_time, string $end_date_time): array
{ {
try { try {
$client = $this->get_http_client_by_provider_id($provider['id']); $client = $this->get_http_client_by_provider_id($provider['id']);
$provider_timezone_object = new DateTimeZone($provider['timezone']); $provider_timezone_object = new DateTimeZone($provider['timezone']);
$response = $this->fetch_events($client, $start_date_time, $end_date_time); $response = $this->fetch_events($client, $start_date_time, $end_date_time);
$xml = new SimpleXMLElement($response->getBody(), 0, false, 'd', true); if (!$response->getBody()) {
log_message('error', 'No response body from fetch_events' . PHP_EOL);
$events = []; return [];
foreach ($xml->children('d', true) as $response) {
$ics_file = (string) $response->propstat->prop->children('cal', true);
$vcalendar = Reader::read($ics_file);
$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, $provider_timezone_object);
}
// $events[] = $this->convert_caldav_event_to_array_event($vcalendar->VEVENT, $provider_timezone_object);
} }
return $events; $xml = new SimpleXMLElement($response->getBody(), 0, false, 'd', true);
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) { } catch (GuzzleException $e) {
$this->handle_guzzle_exception($e, 'Failed to save CalDAV event'); $this->handle_guzzle_exception($e, 'Failed to save CalDAV event');
return []; return [];
} }
} }
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),
);
}
return $events;
}
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;
}
/**
* 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 = 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;
}
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;
}
/** /**
* Common error handling for the CalDAV requests. * Common error handling for the CalDAV requests.
* *
@ -247,6 +355,10 @@ class Caldav_sync
log_message('error', $message . ' ' . $guzzle_info); log_message('error', $message . ' ' . $guzzle_info);
} }
/**
* @throws Exception If there is an invalid CalDAV URL or credentials.
* @throws GuzzleException If theres an issue with the HTTP request.
*/
private function get_http_client(string $caldav_url, string $caldav_username, string $caldav_password): Client private function get_http_client(string $caldav_url, string $caldav_username, string $caldav_password): Client
{ {
if (!filter_var($caldav_url, FILTER_VALIDATE_URL)) { if (!filter_var($caldav_url, FILTER_VALIDATE_URL)) {
@ -254,11 +366,11 @@ class Caldav_sync
} }
if (!$caldav_username) { if (!$caldav_username) {
throw new InvalidArgumentException('Invalid CalDAV username provided: ' . $caldav_username); throw new InvalidArgumentException('Missing CalDAV username');
} }
if (!$caldav_password) { if (!$caldav_password) {
throw new InvalidArgumentException('Invalid CalDAV password provided: ' . $caldav_password); throw new InvalidArgumentException('Missing CalDAV password');
} }
return new Client([ return new Client([
@ -320,7 +432,7 @@ class Caldav_sync
} }
/** /**
* @throws \Jsvrcek\ICS\Exception\CalendarEventException * @throws CalendarEventException
*/ */
private function get_appointment_ics_file( private function get_appointment_ics_file(
array $appointment, array $appointment,
@ -334,7 +446,7 @@ class Caldav_sync
} }
/** /**
* @throws \Jsvrcek\ICS\Exception\CalendarEventException * @throws CalendarEventException
*/ */
private function get_unavailability_ics_file(array $unavailability, array $provider): string private function get_unavailability_ics_file(array $unavailability, array $provider): string
{ {
@ -365,8 +477,26 @@ class Caldav_sync
$end_date_time_object = new DateTime((string) $vevent->DTEND, $utc_timezone_object); $end_date_time_object = new DateTime((string) $vevent->DTEND, $utc_timezone_object);
$end_date_time_object->setTimezone($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 [ return [
'id' => ((string) $vevent->UID) . '-' . random_string(), 'id' => $event_id,
'summary' => (string) $vevent->SUMMARY, 'summary' => (string) $vevent->SUMMARY,
'start_datetime' => $start_date_time_object->format('Y-m-d H:i:s'), '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'), 'end_datetime' => $end_date_time_object->format('Y-m-d H:i:s'),

View file

@ -339,6 +339,24 @@ class Appointments_model extends EA_Model
$this->db->update('appointments', ['id_caldav_calendar' => null], ['id_users_provider' => $provider_id]); $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. * Get the attendants number for the requested period.
* *

View file

@ -1645,7 +1645,7 @@ App.Utils.CalendarDefaultView = (function () {
// Automatically refresh the calendar page every 10 seconds (without loading animation). // Automatically refresh the calendar page every 10 seconds (without loading animation).
setInterval(() => { setInterval(() => {
if ($('.popover').length) { if ($('.popover').length || App.Utils.CalendarSync.isCurrentlySyncing()) {
return; return;
} }

View file

@ -25,6 +25,7 @@ App.Utils.CalendarSync = (function () {
const $reloadAppointments = $('#reload-appointments'); const $reloadAppointments = $('#reload-appointments');
const FILTER_TYPE_PROVIDER = 'provider'; const FILTER_TYPE_PROVIDER = 'provider';
let isSyncing = false;
function hasSync(type) { function hasSync(type) {
const $selectedOption = $selectFilterItem.find('option:selected'); const $selectedOption = $selectFilterItem.find('option:selected');
@ -168,6 +169,9 @@ App.Utils.CalendarSync = (function () {
}) })
.fail(() => { .fail(() => {
App.Layouts.Backend.displayNotification(lang('calendar_sync_failed')); App.Layouts.Backend.displayNotification(lang('calendar_sync_failed'));
})
.always(() => {
isSyncing = false;
}); });
} }
@ -321,6 +325,9 @@ App.Utils.CalendarSync = (function () {
}) })
.fail(() => { .fail(() => {
App.Layouts.Backend.displayNotification(lang('calendar_sync_failed')); App.Layouts.Backend.displayNotification(lang('calendar_sync_failed'));
})
.always(() => {
isSyncing = false;
}); });
} }
@ -372,6 +379,7 @@ App.Utils.CalendarSync = (function () {
function onTriggerSyncClick() { function onTriggerSyncClick() {
const hasGoogleSync = hasSync('google'); const hasGoogleSync = hasSync('google');
isSyncing = true;
if (hasGoogleSync) { if (hasGoogleSync) {
triggerGoogleSync(); triggerGoogleSync();
@ -385,6 +393,10 @@ App.Utils.CalendarSync = (function () {
} }
} }
function isCurrentlySyncing() {
return isSyncing;
}
/** /**
* Initialize the module. * Initialize the module.
*/ */
@ -399,5 +411,6 @@ App.Utils.CalendarSync = (function () {
return { return {
initialize, initialize,
isCurrentlySyncing,
}; };
})(); })();