* @copyright Copyright (c) Alex Tselegidis * @license https://opensource.org/licenses/GPL-3.0 - GPLv3 * @link https://easyappointments.org * @since v1.5.0 * ---------------------------------------------------------------------------- */ 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; /** * Caldav sync library. * * Handles CalDAV related operations using Guzzle. * * @package Libraries */ class Caldav_sync { /** * @var EA_Controller|CI_Controller */ protected EA_Controller|CI_Controller $CI; /** * Caldav_sync constructor. * * 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() { $this->CI = &get_instance(); $this->CI->load->model('appointments_model'); $this->CI->load->model('customers_model'); $this->CI->load->model('providers_model'); $this->CI->load->model('services_model'); $this->CI->load->library('ics_file'); } /** * Add an appointment record to the connected CalDAV calendar. * * @param array $appointment Appointment record. * @param array $service Service record. * @param array $provider Provider record. * @param array $customer Customer record. * * @return string|null Returns the event ID * * @throws CalendarEventException If there's an issue generating the ICS file. */ public function save_appointment(array $appointment, array $service, array $provider, array $customer): ?string { try { $ics_file = $this->get_appointment_ics_file($appointment, $service, $provider, $customer); $client = $this->get_http_client_by_provider_id($provider['id']); $caldav_event_id = $appointment['id_caldav_calendar'] ?: $this->CI->ics_file->generate_uid($appointment['id']); $uri = $this->get_caldav_event_uri($provider['settings']['caldav_url'], $caldav_event_id); $client->request('PUT', $uri, [ 'headers' => [ 'Content-Type' => 'text/calendar', ], 'body' => $ics_file, ]); return $caldav_event_id; } catch (GuzzleException $e) { $this->handle_guzzle_exception($e, 'Failed to save CalDAV appointment event'); return null; } } /** * Add an unavailability record to the connected CalDAV calendar. * * @param array $unavailability Appointment record. * @param array $provider Provider record. * * @return string|null Returns the event ID * * @throws CalendarEventException If there's an issue generating the ICS file. */ public function save_unavailability(array $unavailability, array $provider): ?string { try { if (str_contains((string) $unavailability['id_caldav_calendar'], 'RECURRENCE')) { return $unavailability['id_caldav_calendar'] ?? null; // Do not sync recurring unavailabilities } $ics_file = $this->get_unavailability_ics_file($unavailability, $provider); $client = $this->get_http_client_by_provider_id($provider['id']); $caldav_event_id = $unavailability['id_caldav_calendar'] ?: $this->CI->ics_file->generate_uid($unavailability['id']); $uri = $this->get_caldav_event_uri($provider['settings']['caldav_url'], $caldav_event_id); $client->request('PUT', $uri, [ 'headers' => [ 'Content-Type' => 'text/calendar', ], 'body' => $ics_file, ]); return $caldav_event_id; } catch (GuzzleException $e) { $this->handle_guzzle_exception($e, 'Failed to save CalDAV unavailability event'); return null; } } /** * Delete an existing appointment from Caldav Calendar. * * @param array $provider Provider data. * @param string $caldav_event_id The Caldav Calendar event ID to be removed. */ public function delete_event(array $provider, string $caldav_event_id): void { try { $client = $this->get_http_client_by_provider_id($provider['id']); $uri = $this->get_caldav_event_uri($provider['settings']['caldav_url'], $caldav_event_id); $client->request('DELETE', $uri); } catch (GuzzleException $e) { $this->handle_guzzle_exception($e, 'Failed to delete CalDAV event'); } } /** * Get a Caldav Calendar event. * * @param array $provider Provider Data. * @param string $caldav_event_id CalDAV calendar event ID. * * @return array|null * @throws Exception If there’s an issue parsing the ICS data. */ public function get_event(array $provider, string $caldav_event_id): ?array { try { $client = $this->get_http_client_by_provider_id($provider['id']); $provider_timezone_object = new DateTimeZone($provider['timezone']); $uri = $this->get_caldav_event_uri($provider['settings']['caldav_url'], $caldav_event_id); $response = $client->request('GET', $uri); $ics_file = $response->getBody()->getContents(); $vcalendar = Reader::read($ics_file); return $this->convert_caldav_event_to_array_event($vcalendar->VEVENT, $provider_timezone_object); } catch (GuzzleException $e) { $this->handle_guzzle_exception($e, 'Failed to get CalDAV event'); return null; } } /** * Get all the events between the sync period. * * @param array $provider Provider data. * @param string $start_date_time The start date of sync period. * @param string $end_date_time The end date of sync period. * * @return array * @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); 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 get CalDAV sync events'); 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. * * @param GuzzleException $e * @param string $message * * @return void */ private function handle_guzzle_exception(GuzzleException $e, string $message): void { // Guzzle throws a RequestException for HTTP errors if ($e instanceof RequestException && $e->hasResponse()) { // Handle HTTP error response $response = $e->getResponse(); $status_code = $response->getStatusCode(); $guzzle_info = '(Status Code: ' . $status_code . '): ' . $response->getBody()->getContents() . PHP_EOL; } else { // Handle other request errors $guzzle_info = $e->getMessage(); } 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('Missing CalDAV username'); } if (!$caldav_password) { throw new InvalidArgumentException('Missing CalDAV password'); } return new Client([ 'base_uri' => rtrim($caldav_url, '/') . '/', 'connect_timeout' => 15, 'headers' => [ 'Content-Type' => 'text/xml', ], 'auth' => [$caldav_username, $caldav_password], ]); } /** * @throws Exception * @throws GuzzleException */ public function test_connection(string $caldav_url, string $caldav_username, string $caldav_password): void { try { // Fetch some events to see if the connection is valid $client = $this->get_http_client($caldav_url, $caldav_username, $caldav_password); $start_date_time = date('Y-m-d 00:00:00'); $end_date_time = date('Y-m-d 23:59:59'); $this->fetch_events($client, $start_date_time, $end_date_time); } catch (GuzzleException $e) { $this->handle_guzzle_exception($e, 'Failed to test CalDAV connection'); throw $e; } } /** * @throws GuzzleException */ private function get_http_client_by_provider_id(int $provider_id): Client { $provider = $this->CI->providers_model->find($provider_id); if (!$provider['settings']['caldav_sync']) { throw new RuntimeException('The selected provider does not have the CalDAV sync enabled: ' . $provider_id); } $caldav_url = $provider['settings']['caldav_url']; $caldav_username = $provider['settings']['caldav_username']; $caldav_password = $provider['settings']['caldav_password']; return $this->get_http_client($caldav_url, $caldav_username, $caldav_password); } /** * Generate the event URI, used in various requests. * * @param string $caldav_calendar * @param string|null $caldav_event_id * * @return string */ private function get_caldav_event_uri(string $caldav_calendar, ?string $caldav_event_id = null): string { return $caldav_event_id ? rtrim($caldav_calendar, '/') . '/' . $caldav_event_id . '.ics' : ''; } /** * @throws CalendarEventException */ private function get_appointment_ics_file( array $appointment, array $service, array $provider, array $customer, ): string { $ics_file = $this->CI->ics_file->get_stream($appointment, $service, $provider, $customer); return str_replace('METHOD:PUBLISH', '', $ics_file); } /** * @throws CalendarEventException */ private function get_unavailability_ics_file(array $unavailability, array $provider): string { $ics_file = $this->CI->ics_file->get_unavailability_stream($unavailability, $provider); return str_replace('METHOD:PUBLISH', '', $ics_file); } /** * Convert the VEvent object to an associative array * * @link https://sabre.io/vobject/icalendar * * @param VEvent $vevent Holds the VEVENT information * @param DateTimeZone $timezone_object The date timezone values * * @return array * * @throws DateMalformedStringException */ private function convert_caldav_event_to_array_event(VEvent $vevent, DateTimeZone $timezone_object): array { $utc_timezone_object = new DateTimeZone('UTC'); // Convert from UTC to local provider timezone $start_date_time_object = new DateTime((string) $vevent->DTSTART, $utc_timezone_object); $start_date_time_object->setTimezone($timezone_object); $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' => $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'), 'description' => (string) $vevent->DESCRIPTION, 'status' => (string) $vevent->STATUS, 'location' => (string) $vevent->LOCATION, ]; } /** * @throws GuzzleException * @throws Exception */ private function fetch_events(Client $client, string $start_date_time, string $end_date_time): ResponseInterface { $start_date_time_object = new DateTime($start_date_time); $formatted_start_date_time = $start_date_time_object->format('Ymd\THis\Z'); $end_date_time_object = new DateTime($end_date_time); $formatted_end_date_time = $end_date_time_object->format('Ymd\THis\Z'); return $client->request('REPORT', '', [ 'headers' => [ 'Content-Type' => 'application/xml', 'Depth' => '1', ], 'body' => ' ', ]); } }