From 4f1511980043fca288c364b794e540af1c54f95e Mon Sep 17 00:00:00 2001
From: =?utf8?q?Rapha=C3=ABl=20Gertz?= <git@rapsys.eu>
Date: Thu, 1 Feb 2024 04:39:05 +0100
Subject: [PATCH] Add calendar command

---
 Command/CalendarCommand.php | 492 ++++++++++++++++++++++++++++++++++++
 1 file changed, 492 insertions(+)
 create mode 100644 Command/CalendarCommand.php

diff --git a/Command/CalendarCommand.php b/Command/CalendarCommand.php
new file mode 100644
index 0000000..25c45ca
--- /dev/null
+++ b/Command/CalendarCommand.php
@@ -0,0 +1,492 @@
+<?php
+
+namespace Rapsys\AirBundle\Command;
+
+use Doctrine\Persistence\ManagerRegistry;
+use Symfony\Component\Cache\Adapter\FilesystemAdapter;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
+use Symfony\Component\Routing\RouterInterface;
+use Symfony\Component\Translation\TranslatorInterface;
+use Twig\Extra\Markdown\DefaultMarkdown;
+
+use Rapsys\AirBundle\Entity\Session;
+
+class CalendarCommand extends Command {
+	//Set failure constant
+	const FAILURE = 1;
+
+	///Set success constant
+	const SUCCESS = 0;
+
+	///Config array
+	protected $config;
+
+	/**
+	 * Doctrine instance
+	 *
+	 * @var ManagerRegistry
+	 */
+	protected $doctrine;
+
+	///Locale
+	protected $locale;
+
+	///Translator instance
+	protected $translator;
+
+	/**
+	 * Inject doctrine, container and translator interface
+	 *
+	 * @param ContainerInterface $container The container instance
+	 * @param ManagerRegistry $doctrine The doctrine instance
+	 * @param RouterInterface $router The router instance
+	 * @param TranslatorInterface $translator The translator instance
+	 */
+    public function __construct(ContainerInterface $container, ManagerRegistry $doctrine, RouterInterface $router, TranslatorInterface $translator) {
+		//Call parent constructor
+		parent::__construct();
+
+		//Retrieve config
+		$this->config = $container->getParameter($this->getAlias());
+
+		//Retrieve locale
+		$this->locale = $container->getParameter('kernel.default_locale');
+
+		//Store doctrine
+        $this->doctrine = $doctrine;
+
+		//Store router
+        $this->router = $router;
+
+		//Get router context
+		$context = $this->router->getContext();
+
+		//Set host
+		$context->setHost('airlibre.eu');
+
+		//Set scheme
+		$context->setScheme('https');
+
+		//Set the translator
+		$this->translator = $translator;
+	}
+
+	///Configure attribute command
+	protected function configure() {
+		//Configure the class
+		$this
+			//Set name
+			->setName('rapsysair:calendar')
+			//Set description shown with bin/console list
+			->setDescription('Synchronize sessions in calendar')
+			//Set description shown with bin/console --help airlibre:attribute
+			->setHelp('This command synchronize sessions in google calendar');
+	}
+
+	///Process the attribution
+	protected function execute(InputInterface $input, OutputInterface $output) {
+		//Compute period
+		$period = new \DatePeriod(
+			//Start from last week
+			new \DateTime('-1 week'),
+			//Iterate on each day
+			new \DateInterval('P1D'),
+			//End with next 2 week
+			new \DateTime('+2 week')
+		);
+
+		//Retrieve events to update
+		$sessions = $this->doctrine->getRepository(Session::class)->fetchAllByDatePeriod($period, $this->locale);
+
+		//Markdown converted instance
+		$markdown = new DefaultMarkdown;
+
+		//Retrieve cache object
+		//XXX: by default stored in /tmp/symfony-cache/@/W/3/6SEhFfeIW4UMDlAII+Dg
+		//XXX: stored in %kernel.project_dir%/var/cache/airlibre/0/P/IA20X0K4dkMd9-+Ohp9Q
+		$cache = new FilesystemAdapter($this->config['cache']['namespace'], $this->config['cache']['lifetime'], $this->config['cache']['directory']);
+
+		//Retrieve calendars
+		$cacheCalendars = $cache->getItem('calendars');
+
+		//Without calendars
+		if (!$cacheCalendars->isHit()) {
+			//Return failure
+			return self::FAILURE;
+		}
+
+		//Retrieve calendars
+		$calendars = $cacheCalendars->get();
+
+		//XXX: calendars content
+		#var_export($calendars);
+
+		//Check expired token
+		foreach($calendars as $clientId => $client) {
+			//Get google client
+			$googleClient = new \Google\Client(['application_name' => $client['project'], 'client_id' => $clientId, 'client_secret' => $client['secret'], 'redirect_uri' => $client['redirect']]);
+
+			//Iterate on each tokens
+			foreach($client['tokens'] as $tokenId => $token) {
+				//Set token
+				$googleClient->setAccessToken(
+					[
+						'access_token' => $tokenId,
+						'refresh_token' => $token['refresh'],
+						'expires_in' => $token['expire'],
+						'scope' => $token['scope'],
+						'token_type' => $token['type'],
+						'created' => $token['created']
+					]
+				);
+
+				//With expired token
+				if ($exp = $googleClient->isAccessTokenExpired()) {
+					//Refresh token
+					if ($googleClient->getRefreshToken()) {
+						//Retrieve refreshed token
+						$googleToken = $googleClient->fetchAccessTokenWithRefreshToken($googleClient->getRefreshToken());
+
+						//Add refreshed token
+						$calendars[$clientId]['tokens'][$googleToken['access_token']] = [
+							'calendar' => $token['calendar'],
+							'prefix' => $token['prefix'],
+							'refresh' => $googleToken['refresh_token'],
+							'expire' => $googleToken['expires_in'],
+							'scope' => $googleToken['scope'],
+							'type' => $googleToken['token_type'],
+							'created' => $googleToken['created']
+						];
+
+						//Remove old token
+						unset($calendars[$clientId]['tokens'][$tokenId]);
+					} else {
+						//Drop token
+						unset($calendars[$clientId]['tokens'][$tokenId]);
+
+						//Without tokens
+						if (empty($calendars[$clientId]['tokens'])) {
+							//Drop client
+							unset($calendars[$clientId]);
+						}
+
+						//Drop token and report
+						echo 'Token '.$tokenId.' for calendar '.$token['calendar'].' has expired and is not refreshable'."\n";
+
+						//Return failure
+						//XXX: we want that mail and stop here
+						return self::FAILURE;
+					}
+				}
+			}
+		}
+
+		//Save calendars
+		$cacheCalendars->set($calendars);
+
+		//Save calendar
+		$cache->save($cacheCalendars);
+
+		//Iterate on each calendar client
+		foreach($calendars as $clientId => $client) {
+			//Get google client
+			$googleClient = new \Google\Client(['application_name' => $client['project'], 'client_id' => $clientId, 'client_secret' => $client['secret'], 'redirect_uri' => $client['redirect']]);
+
+			//Iterate on each tokens
+			foreach($client['tokens'] as $tokenId => $token) {
+				//Set token
+				$googleClient->setAccessToken(
+					[
+						'access_token' => $tokenId,
+						'refresh_token' => $token['refresh'],
+						'expires_in' => $token['expire'],
+						'scope' => $token['scope'],
+						'token_type' => $token['type'],
+						'created' => $token['created']
+					]
+				);
+
+				//With expired token
+				if ($exp = $googleClient->isAccessTokenExpired()) {
+					//Last chance to skip this run
+					continue;
+				}
+
+				//Get google calendar
+				$googleCalendar = new \Google\Service\Calendar($googleClient);
+
+				//Retrieve calendar
+				try {
+					$calendar = $googleCalendar->calendars->get($token['calendar']);
+				//Catch exception
+				} catch(\Google\Service\Exception $e) {
+					//Display exception
+					//TODO: handle codes here https://developers.google.com/calendar/api/guides/errors
+					echo 'Exception '.$e->getCode().':'.$e->getMessage().' in '.$e->getFile().' +'.$e->getLine()."\n";
+					echo $e->getTraceAsString()."\n";
+
+					//Return failure
+					return self::FAILURE;
+				}
+
+				//Init events
+				$events = [];
+
+				//Set filters
+				$filters = [
+					//XXX: show even deleted event to be able to update them
+					'showDeleted' => true,
+					//TODO: fetch events one day before and one day after to avoid triggering double insert duplicate key 409 errors :=) on google
+					'timeMin' => $period->getStartDate()->format(\DateTime::ISO8601),
+					'timeMax' => $period->getEndDate()->format(\DateTime::ISO8601)
+					/*, 'iCalUID' => 'airlibre/?????'*//*'orderBy' => 'startTime', */
+				];
+
+				//Retrieve event collection
+				$googleEvents = $googleCalendar->events->listEvents($token['calendar'], $filters);
+
+				//Iterate until reached end
+				while (true) {
+					//Iterate on each event
+					foreach ($googleEvents->getItems() as $event) {
+						//Store event by id
+						if (preg_match('/^'.$token['prefix'].'([0-9]+)$/', $id = $event->getId(), $matches)) {
+							$events[$matches[1]] = $event;
+						//XXX: 3rd party events with id not matching prefix are skipped
+						#} else {
+						#	echo 'Skipping '.$event->getId().':'.$event->getSummary()."\n";*/
+						}
+					}
+
+					//Get page token
+					$pageToken = $googleEvents->getNextPageToken();
+
+					//Handle next page
+					if ($pageToken) {
+						//Replace collection with next one
+						$googleEvents = $service->events->listEvents($token['calendar'], $filters+['pageToken' => $pageToken]);
+					} else {
+						break;
+					}
+				}
+
+				//Iterate on each session to sync
+				foreach($sessions as $sessionId => $session) {
+					//Init shared properties
+					//TODO: validate for constraints here ??? https://developers.google.com/calendar/api/guides/extended-properties
+					$shared = [
+						'gps' => $session['l_latitude'].','.$session['l_longitude']
+					];
+
+					//Init source
+					$source = [
+						'title' => $this->translator->trans('Session %id% by %pseudonym%', ['%id%' => $sessionId, '%pseudonym%' => $session['au_pseudonym']]).' '.$this->translator->trans('at '.$session['l_title']),
+						'url' => $this->router->generate('rapsys_air_session_view', ['id' => $sessionId], UrlGeneratorInterface::ABSOLUTE_URL)
+					];
+
+					//Init description
+					#$description = '<dl><dt>Description</dt><dd>'.$markdown->convert(strip_tags(str_replace(["\r", "\n\n"], ['', "\n"], $session['p_description']))).'</dd></dl>';
+					$description = '<dl><dt>Description</dt><dd>'.$markdown->convert(strip_tags($session['p_description'])).'</dd></dl>';
+
+					//Add class when available
+					if (!empty($session['p_class'])) {
+						$shared['class'] = $session['p_class'];
+						#$description .= '<dl><dt>Classe</dt><dd>'.$markdown->convert(strip_tags(str_replace(["\r", "\n\n"], ['', "\n"], $session['p_class']))).'</dd></dl>';
+						$description .= '<dl><dt>Classe</dt><dd><p>'.$session['p_class'].'</p></dd></dl>';
+					}
+
+					//Add contact when available
+					if (!empty($session['p_contact'])) {
+						$shared['contact'] = $session['p_contact'];
+						$description .= '<dl><dt>Contacter</dt><dd><p>'.$session['p_contact'].'</p></dd></dl>';
+					}
+
+					//Add donate when available
+					if (!empty($session['p_donate'])) {
+						$shared['donate'] = $session['p_donate'];
+						$description .= '<dl><dt>Contribuer</dt><dd><p>'.$session['p_donate'].'</p></dd></dl>';
+					}
+
+					//Add link when available
+					if (!empty($session['p_link'])) {
+						$shared['link'] = $session['p_link'];
+						$description .= '<dl><dt>Site</dt><dd><p>'.$session['p_link'].'</p></dd></dl>';
+					}
+
+					//Add profile when available
+					if (!empty($session['p_profile'])) {
+						$shared['profile'] = $session['p_profile'];
+						$description .= '<dl><dt>Réseau social</dt><dd><p>'.$session['p_profile'].'</p></dd></dl>';
+					}
+
+					//Locked session
+					if (!empty($session['locked']) && $events[$sessionId]) {
+						//With events
+						if (!empty($event = $events[$sessionId])) {
+							try {
+								//Delete the event
+								$googleCalendar->events->delete($token['calendar'], $event->getId());
+							//Catch exception
+							} catch(\Google\Service\Exception $e) {
+								//Display exception
+								//TODO: handle codes here https://developers.google.com/calendar/api/guides/errors
+								echo 'Exception '.$e->getCode().':'.$e->getMessage().' in '.$e->getFile().' +'.$e->getLine()."\n";
+								echo $e->getTraceAsString()."\n";
+
+								//Return failure
+								return self::FAILURE;
+							}
+						}
+					//Without event
+					} elseif (empty($events[$sessionId])) {
+						//Init event
+						$event = new \Google\Service\Calendar\Event(
+							[
+								//TODO: replace 'airlibre' with $this->config['calendar']['prefix'] when possible with prefix validating [a-v0-9]{5,}
+								//XXX: see https://developers.google.com/calendar/api/v3/reference/events/insert#id
+								'id' => $token['prefix'].$sessionId,
+								'summary' => $session['au_pseudonym'].' '.$this->translator->trans('at '.$session['l_short']),
+								#'description' => $markdown->convert(strip_tags($session['p_description'])),
+								'description' => $description,
+								'status' => empty($session['a_canceled'])?'confirmed':'cancelled',
+								'location' => implode(' ', [$session['l_address'], $session['l_zipcode'], $session['l_city']]),
+								'source' => $source,
+								'extendedProperties' => [
+									'shared' => $shared
+								],
+								//TODO: colorId ?
+								//TODO: attendees[] ?
+								'start' => [
+									'dateTime' => $session['start']->format(\DateTime::ISO8601)
+								],
+								'end' => [
+									'dateTime' => $session['stop']->format(\DateTime::ISO8601)
+								]
+							]
+						);
+
+						try {
+							//Insert the event
+							$googleCalendar->events->insert($token['calendar'], $event);
+						//Catch exception
+						} catch(\Google\Service\Exception $e) {
+							//Display exception
+							//TODO: handle codes here https://developers.google.com/calendar/api/guides/errors
+							echo 'Exception '.$e->getCode().':'.$e->getMessage().' in '.$e->getFile().' +'.$e->getLine()."\n";
+							echo $e->getTraceAsString()."\n";
+
+							//Return failure
+							return self::FAILURE;
+						}
+					// With event
+					} else {
+						//Set event
+						$event = $events[$sessionId];
+
+						//With updated event
+						#if ($session['updated'] >= (new \DateTime($event->getUpdated()))) {
+						{
+							//Set summary
+							$event->setSummary($session['au_pseudonym'].' '.$this->translator->trans('at '.$session['l_short']));
+
+							//Set description
+							$event->setDescription($description);
+
+							//Set status
+							$event->setStatus(empty($session['a_canceled'])?'confirmed':'cancelled');
+
+							//Set location
+							$event->setLocation(implode(' ', [$session['l_address'], $session['l_zipcode'], $session['l_city']]));
+
+							//Get source
+							$eventSource = $event->getSource();
+
+							//Update source title
+							$eventSource->setTitle($source['title']);
+
+							//Update source url
+							$eventSource->setUrl($source['url']);
+
+							//Set source
+							#$event->setSource($source);
+
+							//Get extended properties
+							$extendedProperties = $event->getExtendedProperties();
+
+							//Update shared
+							$extendedProperties->setShared($shared);
+
+							//TODO: colorId ?
+							//TODO: attendees[] ?
+
+							//Set start
+							$start = $event->getStart();
+
+							//Update start datetime
+							$start->setDateTime($session['start']->format(\DateTime::ISO8601));
+
+							//Set end
+							$end = $event->getEnd();
+
+							//Update stop datetime
+							$end->setDateTime($session['stop']->format(\DateTime::ISO8601));
+
+							try {
+								//Insert the event
+								$updatedEvent = $googleCalendar->events->update($token['calendar'], $event->getId(), $event);
+							//Catch exception
+							} catch(\Google\Service\Exception $e) {
+								//Display exception
+								//TODO: handle codes here https://developers.google.com/calendar/api/guides/errors
+								echo 'Exception '.$e->getCode().':'.$e->getMessage().' in '.$e->getFile().' +'.$e->getLine()."\n";
+								echo $e->getTraceAsString()."\n";
+
+								//Return failure
+								return self::FAILURE;
+							}
+						}
+
+						//Drop from events array
+						unset($events[$sessionId]);
+					}
+				}
+
+				//Remaining events to drop
+				foreach($events as $eventId => $event) {
+					//Non canceled events
+					if ($event->getStatus() == 'confirmed') {
+						try {
+							//Delete the event
+							$googleCalendar->events->delete($token['calendar'], $event->getId());
+						//Catch exception
+						} catch(\Google\Service\Exception $e) {
+							//Display exception
+							//TODO: handle codes here https://developers.google.com/calendar/api/guides/errors
+							echo 'Exception '.$e->getCode().':'.$e->getMessage().' in '.$e->getFile().' +'.$e->getLine()."\n";
+							echo $e->getTraceAsString()."\n";
+
+							//Return failure
+							return self::FAILURE;
+						}
+					}
+				}
+			}
+		}
+
+		//Return success
+		return self::SUCCESS;
+	}
+
+	/**
+	 * Return the bundle alias
+	 *
+	 * {@inheritdoc}
+	 */
+	public function getAlias(): string {
+		return 'rapsys_air';
+	}
+}
-- 
2.41.3