Skip to content

Commit

Permalink
Merge pull request #69 from alextselegidis/develop
Browse files Browse the repository at this point in the history
Improve the CalDAV syncing mechanism so that it connects to more syst…
  • Loading branch information
tm8544 authored Nov 26, 2024
2 parents f7f11ab + 37e3618 commit 686a923
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 686a923

Please sign in to comment.