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 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

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).
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 @@ class Caldav extends EA_Controller
$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 @@ class Caldav extends EA_Controller
$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 @@ class Caldav extends EA_Controller
}
}
$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;

View file

@ -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 @@ class Caldav_sync
*
* @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 @@ class Caldav_sync
*
* @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 @@ class Caldav_sync
* @param string $caldav_event_id CalDAV calendar event ID.
*
* @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
{
@ -182,42 +190,142 @@ class Caldav_sync
* @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);
$xml = new SimpleXMLElement($response->getBody(), 0, false, 'd', true);
$events = [];
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);
if (!$response->getBody()) {
log_message('error', 'No response body from fetch_events' . PHP_EOL);
return [];
}
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) {
$this->handle_guzzle_exception($e, 'Failed to save CalDAV event');
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.
*
@ -247,6 +355,10 @@ class Caldav_sync
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
{
if (!filter_var($caldav_url, FILTER_VALIDATE_URL)) {
@ -254,11 +366,11 @@ class Caldav_sync
}
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 @@ class Caldav_sync
}
/**
* @throws \Jsvrcek\ICS\Exception\CalendarEventException
* @throws CalendarEventException
*/
private function get_appointment_ics_file(
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
{
@ -365,8 +477,26 @@ class Caldav_sync
$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'),

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]);
}
/**
* 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.
*

View file

@ -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;
}

View file

@ -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,
};
})();