Skip to content

Commit

Permalink
Improve the CalDAV syncing mechanism so that it connects to more syst…
Browse files Browse the repository at this point in the history
…ems without problems (#1622)
  • Loading branch information
alextselegidis committed Nov 25, 2024
1 parent 947bd79 commit 37e3618
Show file tree
Hide file tree
Showing 6 changed files with 193 additions and 31 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 8 additions & 8 deletions application/controllers/Caldav.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand All @@ -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);
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand Down
174 changes: 152 additions & 22 deletions application/libraries/Caldav_sync.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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()
{
Expand All @@ -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
{
Expand Down Expand Up @@ -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']);
Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -247,18 +355,22 @@ 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)) {
throw new InvalidArgumentException('Invalid CalDAV URL provided: ' . $caldav_url);
}

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([
Expand Down Expand Up @@ -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,
Expand All @@ -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
{
Expand Down Expand Up @@ -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'),
Expand Down
18 changes: 18 additions & 0 deletions application/models/Appointments_model.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
2 changes: 1 addition & 1 deletion assets/js/utils/calendar_default_view.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Loading

0 comments on commit 37e3618

Please sign in to comment.