X-Git-Url: https://git.rapsys.eu/airbundle/blobdiff_plain/278a472cf91b7936dca2ea7629a931be4ff74740..ed0a31d7a909f78a031e955c87ae55323b6f7ba8:/Command/Calendar2Command.php

diff --git a/Command/Calendar2Command.php b/Command/Calendar2Command.php
index 257d2e3..52eafb9 100644
--- a/Command/Calendar2Command.php
+++ b/Command/Calendar2Command.php
@@ -15,13 +15,16 @@ use Doctrine\Persistence\ManagerRegistry;
 
 use Google\Client;
 use Google\Service\Calendar;
-use Google\Service\Oauth2;
+use Google\Service\Calendar\Event;
+use Google\Service\Calendar\EventExtendedProperties;
+use Google\Service\Calendar\EventSource;
 
-use Symfony\Component\Cache\Adapter\FilesystemAdapter;
 use Symfony\Component\Console\Input\InputInterface;
 use Symfony\Component\Console\Output\OutputInterface;
 use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
 use Symfony\Component\Routing\RouterInterface;
+use Symfony\Contracts\Cache\CacheInterface;
+use Symfony\Contracts\Cache\ItemInterface;
 use Symfony\Contracts\Translation\TranslatorInterface;
 
 use Twig\Extra\Markdown\DefaultMarkdown;
@@ -54,150 +57,557 @@ class Calendar2Command extends Command {
 	protected string $help = 'This command synchronize sessions in users\' google calendar';
 
 	/**
-	 * Set markdown instance
+	 * Set domain
 	 */
-	#private DefaultMarkdown $markdown;
+	protected string $domain;
 
 	/**
-	 * Set date period instance
+	 * Set item
+	 *
+	 * Cache item instance
 	 */
-	#private \DatePeriod $period;
+	protected ItemInterface $item;
+
+	/**
+	 * Set prefix
+	 */
+	protected string $prefix;
+
+	/**
+	 * Set service
+	 *
+	 * Google calendar instance
+	 */
+	protected Calendar $service;
 
 	/**
 	 * {@inheritdoc}
+	 *
+	 * @param CacheInterface $cache The cache instance
+	 * @param Client $google The google client instance
+	 * @param DefaultMarkdown $markdown The markdown instance
 	 */
-	public function __construct(protected ManagerRegistry $doctrine, protected string $locale, protected RouterInterface $router, protected SluggerUtil $slugger, protected TranslatorInterface $translator, protected Client $google, protected DefaultMarkdown $markdown) {
+	public function __construct(protected ManagerRegistry $doctrine, protected string $locale, protected RouterInterface $router, protected SluggerUtil $slugger, protected TranslatorInterface $translator, protected CacheInterface $cache, protected Client $google, protected DefaultMarkdown $markdown) {
 		//Call parent constructor
 		parent::__construct($this->doctrine, $this->locale, $this->router, $this->slugger, $this->translator);
 
 		//Replace google client redirect uri
 		$this->google->setRedirectUri($this->router->generate($this->google->getRedirectUri(), [], UrlGeneratorInterface::ABSOLUTE_URL));
-
-		/*
-		//Set google client
-		$this->client = new Client(
-			[
-				'application_name' => $_ENV['RAPSYSAIR_GOOGLE_PROJECT'],
-				'client_id' => $_ENV['GOOGLE_CLIENT_ID'],
-				'client_secret' => $_ENV['GOOGLE_CLIENT_SECRET'],
-				'redirect_uri' => $this->router->generate('rapsysair_google_callback', [], UrlGeneratorInterface::ABSOLUTE_URL),
-				'scopes' => $this->scopes,
-				'access_type' => 'offline',
-				#'login_hint' => $user->getMail(),
-				//XXX: see https://stackoverflow.com/questions/10827920/not-receiving-google-oauth-refresh-token
-				#'approval_prompt' => 'force'
-				'prompt' => 'consent'
-			]
-		);
-
-		//Set Markdown instance
-		$this->markdown = new DefaultMarkdown;*/
 	}
 
-	/**
-	 * Configure attribute command
-	 */
-	/*protected function configure() {
-		//Configure the class
-		$this
-			//Set name
-			->setName('rapsysair:calendar2')
-			//Set description shown with bin/console list
-			->setDescription('Synchronize sessions in users\' calendar')
-			//Set description shown with bin/console --help airlibre:attribute
-			->setHelp('This command synchronize sessions in users\' google calendar');
-	}*/
-
 	/**
 	 * Process the attribution
 	 */
 	protected function execute(InputInterface $input, OutputInterface $output): int {
-		//Set 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')
-		);
+		//Get domain
+		$this->domain = $this->router->getContext()->getHost();
+
+		//Get manager
+		$manager = $this->doctrine->getManager();
+
+		//Convert from any to latin, then to ascii and lowercase
+		$trans = \Transliterator::create('Any-Latin; Latin-ASCII; Lower()');
+
+		//Replace every non alphanumeric character by dash then trim dash
+		$this->prefix = preg_replace(['/(\.[a-z0-9]+)+$/', '/[^a-v0-9]+/'], '', $trans->transliterate($this->domain));
+
+		//With too short prefix
+		if ($this->prefix === null || strlen($this->prefix) < 4) {
+			//Throw domain exception
+			throw new \DomainException('Prefix too short: '.$this->prefix);
+		}
 
 		//Iterate on google tokens
 		foreach($tokens = $this->doctrine->getRepository(GoogleToken::class)->findAllIndexed() as $tid => $token) {
+			//Clear client cache before changing access token
+			//TODO: set a per token cache ?
+			$this->google->getCache()->clear();
+
+			//Set access token
+			$this->google->setAccessToken(
+				[
+					'access_token' => $token['access'],
+					'refresh_token' => $token['refresh'],
+					'created' => $token['created']->getTimestamp(),
+					'expires_in' => $token['expired']->getTimestamp() - (new \DateTime('now'))->getTimestamp()
+				]
+			);
+
+			//With expired token
+			if ($this->google->isAccessTokenExpired()) {
+				//Refresh token
+				if (($gRefresh = $this->google->getRefreshToken()) && ($gToken = $this->google->fetchAccessTokenWithRefreshToken($gRefresh)) && empty($gToken['error'])) {
+					//Get google token
+					$googleToken = $this->doctrine->getRepository(GoogleToken::class)->findOneById($token['id']);
+
+					//Set access token
+					$googleToken->setAccess($gToken['access_token']);
+
+					//Set expires
+					$googleToken->setExpired(new \DateTime('+'.$gToken['expires_in'].' second'));
+
+					//Set refresh
+					$googleToken->setRefresh($gToken['refresh_token']);
+
+					//Queue google token save
+					$manager->persist($googleToken);
+
+					//Flush to get the ids
+					$manager->flush();
+				//Refresh failed
+				} else {
+					//Show error
+					fprintf(STDERR, 'Unable to refresh token %d: %s', $token['id'], $gToken['error']?:'');
+
+					//TODO: warn user by mail ?
+
+					//Skip to next token
+					continue;
+				}
+			}
+
+			//Get google calendar service
+			$this->service = new Calendar($this->google);
+
 			//Iterate on google calendars
 			foreach($calendars = $token['calendars'] as $cid => $calendar) {
-				#$calendar['synchronized']
-				var_dump($token);
+				//Set start
+				$synchronized = null;
 
-				//TODO: see if we may be smarter here ?
+				//Set cache key
+				$cacheKey = 'command.calendar2.'.$this->slugger->short($calendar['mail']);
 
-				//TODO: load all calendar events here ?
+				//XXX: TODO: remove DEBUG
+				#$this->cache->delete($cacheKey);
+
+				//Retrieve calendar events
+				try {
+					//Get events
+					$events = $this->cache->get(
+						//Cache key
+						//XXX: set to command.calendar2.$mail
+						$cacheKey,
+						//Fetch mail calendar event list
+						function (ItemInterface $item) use ($calendar, &$synchronized): array {
+							//Expire after 1h
+							$item->expiresAfter(3600);
+
+							//Set synchronized
+							$synchronized = new \DateTime('now');
+
+							//Init events
+							$events = [];
+
+							//Set filters
+							//TODO: add a filter to only retrieve
+							$filters = [
+								//XXX: every event even deleted one to be able to update them
+								'showDeleted' => true,
+								//XXX: every instances
+								'singleEvents' => false,
+								//XXX: select only domain events
+								'privateExtendedProperty' => 'domain='.$this->domain
+								#TODO: restrict events even more by time or updated datetime ? (-1 week to +2 week)
+								//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', */
+								//updatedMin => new \DateTime('-1 week') ?
+							];
+
+							//Set page token
+							$pageToken = null;
+
+							//Iterate until next page token is null
+							do {
+								//Get calendar events list
+								//XXX: see vendor/google/apiclient-services/src/Calendar/Resource/Events.php +289
+								$eventList = $this->service->events->listEvents($calendar['mail'], ['pageToken' => $pageToken]+$filters);
+
+								//Iterate on items
+								foreach($eventList->getItems() as $event) {
+									//With extended properties
+									if (($properties = $event->getExtendedProperties()) && ($private = $properties->getPrivate()) && isset($private['id']) && ($id = $private['id']) && isset($private['domain']) && $private['domain'] == $this->domain) {
+										//Add event
+										$events[$id] = $event;
+									//XXX: 3rd party events without matching prefix and id are skipped
+									#} else {
+									#	#echo 'Skipping '.$event->getId().':'.$event->getSummary()."\n";
+									#	echo 'Skipping '.$id.':'.$event->getSummary()."\n";
+									}
+								}
+							} while ($pageToken = $eventList->getNextPageToken());
+
+							//Return events
+							return $events;
+						}
+					);
+				//Catch exception
+				} catch(\Google\Service\Exception $e) {
+					//With 401 or code
+					//XXX: see https://cloud.google.com/apis/design/errors
+					if ($e->getCode() == 401 || $e->getCode() == 403) {
+						//Show error
+						fprintf(STDERR, 'Unable to list calendar %d events: %s', $calendar['id'], $e->getMessage()?:'');
+
+						//TODO: warn user by mail ?
+
+						//Skip to next token
+						continue;
+					}
+
+					//Throw error
+					throw new \LogicException('Calendar event list failed', 0, $e);
+				}
+
+				//Store cache item
+				$this->item = $this->cache->getItem($cacheKey);
 
 				//Iterate on sessions to update
-				foreach($sessions = $this->doctrine->getRepository(Session::class)->findAllByUserIdSynchronized($token['uid'], $calendar['synchronized']) as $session) {
-					//TODO: insert/update/delete events here ?
+				foreach($this->doctrine->getRepository(Session::class)->findAllByUserIdSynchronized($token['uid'], $calendar['synchronized']) as $session) {
+					//Start exception catching
+					try {
+						//Without event
+						if (!isset($events[$session['id']])) {
+							//Insert event
+							$this->insert($calendar['mail'], $session);
+						//With locked session
+						} elseif (($event = $events[$session['id']]) && !empty($session['locked'])) {
+							//Delete event
+							$sid = $this->delete($calendar['mail'], $event);
+
+							//Drop from events array
+							unset($events[$sid]);
+						//With event to update
+						} elseif ($session['modified'] > (new \DateTime($event->getUpdated()))) {
+							//Update event
+							$sid = $this->update($calendar['mail'], $event, $session);
+
+							//Drop from events array
+							unset($events[$sid]);
+						}
+					//Catch exception
+					} catch(\Google\Service\Exception $e) {
+						//Identifier already exists
+						if ($e->getCode() == 409) {
+							//Get calendar event
+							//XXX: see vendor/google/apiclient-services/src/Calendar/Resource/Events.php +81
+							$event = $this->service->events->get($calendar['mail'], $this->prefix.$session['id']);
+
+							//Update required
+							if ($session['modified'] > (new \DateTime($event->getUpdated()))) {
+								//Update event
+								$sid = $this->update($calendar['mail'], $event, $session);
+
+								//Drop from events array
+								unset($events[$sid]);
+							}
+						//TODO: handle other codes gracefully ? (503 & co)
+						//Other errors
+						} else {
+							//Throw error
+							throw new \LogicException(sprintf('Calendar %s event %s operation failed', $calendar, $this->prefix.$session['id']), 0, $e);
+						}
+					}
 				}
 
-				//TODO: delete remaining events here ?
-			}
-		}
+				//Get all sessions
+				$sessions = $this->doctrine->getRepository(Session::class)->findAllByUserIdSynchronized($token['uid']);
 
-		//TODO: get user filter ? (users_subscriptions+users_dances)
-
-		//TODO: XXX: or fetch directly the events updated since synchronized + matching rubscriptions and/or dances
-
-		#var_dump($tokens);
-		exit;
-		
-		//Set sql request
-		$sql =<<<SQL
-SELECT
-	b.*,
-	GROUP_CONCAT(us.user_id) AS users
-FROM (
-	SELECT
-		a.*,
-		GROUP_CONCAT(ud.dance_id) AS dances
-	FROM (
-		SELECT
-			t.id AS tid,
-			t.mail AS gmail,
-			t.user_id,
-			t.access,
-			t.refresh,
-			t.expired,
-			GROUP_CONCAT(c.id) AS cids,
-			GROUP_CONCAT(c.mail) AS cmails,
-			GROUP_CONCAT(c.summary) AS csummaries,
-			GROUP_CONCAT(c.synchronized) AS csynchronizeds
-		FROM google_tokens AS t
-		JOIN google_calendars AS c ON (c.google_token_id = t.id)
-		GROUP BY t.id
-		ORDER BY NULL
-		LIMIT 100000
-	) AS a
-	LEFT JOIN users_dances AS ud ON (ud.user_id = a.user_id)
-	GROUP BY a.tid
-	ORDER BY NULL
-	LIMIT 100000
-) AS b
-LEFT JOIN users_subscriptions AS us ON (us.subscriber_id = b.user_id)
-GROUP BY b.tid
-ORDER BY NULL
-SQL;
-		#$sessions = $this->doctrine->getRepository(Session::class)->findAllByDanceUserModified($filter['dance'], $filter['user'], $calendar['synchronized']);
-		//Iterate on google tokens
-		foreach($tokens as $token) {
-			//TODO: clear google client cache
-			//TODO: set google token
-			//Iterate on google calendars
-			foreach($calendars as $calendar) {
-				//Fetch sessions to sync
-				$sessions = $this->doctrine->getRepository(Session::class)->findAllByDanceUserModified($filter['dance'], $filter['user'], $calendar['synchronized']);
+				//Remaining events to drop
+				foreach($events as $eid => $event) {
+					//With events updated since last synchronized
+					if ($event->getStatus() == 'confirmed' && (new \DateTime($event->getUpdated())) > $calendar['synchronized']) {
+						//TODO: Add a try/catch here to handle error codes gracefully (503 & co) ?
+						//With event to update
+						if (isset($sessions[$eid]) && ($session = $sessions[$eid]) && empty($session['locked'])) {
+							//Update event
+							$sid = $this->update($calendar['mail'], $event, $session);
+
+							//Drop from events array
+							unset($events[$sid]);
+						//With locked or unknown session
+						} else {
+							//Delete event
+							$sid = $this->delete($calendar['mail'], $event);
+
+							//Drop from events array
+							unset($events[$sid]);
+						}
+					}
+				}
+
+				//Persist cache item
+				$this->cache->commit();
+
+				//With synchronized
+				//XXX: only store synchronized on run without caching
+				if ($synchronized) {
+					//Get google calendar
+					$googleCalendar = $this->doctrine->getRepository(GoogleCalendar::class)->findOneById($calendar['id']);
+
+					//Set synchronized
+					$googleCalendar->setSynchronized($synchronized);
+
+					//Queue google calendar save
+					$manager->persist($googleCalendar);
+
+					//Flush to get the ids
+					$manager->flush();
+				}
 			}
 		}
 
 		//Return success
 		return self::SUCCESS;
 	}
+
+	/**
+	 * Delete event
+	 *
+	 * @param string $calendar The calendar mail
+	 * @param Event $event The google event instance
+	 * @return void
+	 */
+	function delete(string $calendar, Event $event): int {
+		//Get cache events
+		$cacheEvents = $this->item->get();
+
+		//Get event id
+		$eid = $event->getId();
+
+		//Delete the event
+		$this->service->events->delete($calendar, $eid);
+
+		//Set sid
+		$sid = intval(substr($event->getId(), strlen($this->prefix)));
+
+		//Remove from events and cache events
+		unset($cacheEvents[$sid]);
+
+		//Set cache events
+		$this->item->set($cacheEvents);
+
+		//Save cache item
+		$this->cache->saveDeferred($this->item);
+
+		//Return session id
+		return $sid;
+	}
+
+	/**
+	 * Fill event
+	 *
+	 * TODO: add domain based/calendar mail specific templates ?
+	 *
+	 * @param array $session The session instance
+	 * @param ?Event $event The event instance
+	 * @return Event The filled event
+	 */
+	function fill(array $session, ?Event $event = null): Event {
+		//Init private properties
+		$private = [
+			'id' => $session['id'],
+			'domain' => $this->domain,
+			'updated' => $session['modified']->format(\DateTime::ISO8601)
+		];
+
+		//Init shared properties
+		//TODO: validate for constraints here ??? https://developers.google.com/calendar/api/guides/extended-properties
+		//TODO: drop shared as unused ???
+		$shared = [
+			'gps' => $session['l_latitude'].','.$session['l_longitude']
+		];
+
+		//Init source
+		$source = new EventSource(
+			[
+				'title' => $this->translator->trans('%dance% %id% by %pseudonym%', ['%id%' => $session['id'], '%dance%' => $this->translator->trans($session['ad_name'].' '.lcfirst($session['ad_type'])), '%pseudonym%' => $session['au_pseudonym']]).' '.$this->translator->trans('at '.$session['l_title']),
+				'url' => $this->router->generate('rapsysair_session_view', ['id' => $session['id'], 'location' => $this->slugger->slug($this->translator->trans($session['l_title'])), 'dance' => $this->slugger->slug($this->translator->trans($session['ad_name'].' '.lcfirst($session['ad_type']))), 'user' => $this->slugger->slug($session['au_pseudonym'])], UrlGeneratorInterface::ABSOLUTE_URL)
+			]
+		);
+
+		//Init location
+		$description = 'Emplacement :'."\n".$this->translator->trans($session['l_description']);
+		$shared['location'] = strip_tags($this->translator->trans($session['l_description']));
+
+		//Add description when available
+		if(!empty($session['p_description'])) {
+			$description .= "\n\n".'Description :'."\n".strip_tags(preg_replace('!<a href="([^"]+)"(?: title="[^"]+")?'.'>([^<]+)</a>!', '\1', $this->markdown->convert(strip_tags($session['p_description']))));
+			$shared['description'] = $this->markdown->convert(strip_tags($session['p_description']));
+		}
+
+		//Add class when available
+		if (!empty($session['p_class'])) {
+			$description .= "\n\n".'Classe :'."\n".$session['p_class'];
+			$shared['class'] = $session['p_class'];
+		}
+
+		//Add contact when available
+		if (!empty($session['p_contact'])) {
+			$description .= "\n\n".'Contact :'."\n".$session['p_contact'];
+			$shared['contact'] = $session['p_contact'];
+		}
+
+		//Add donate when available
+		if (!empty($session['p_donate'])) {
+			$description .= "\n\n".'Contribuer :'."\n".$session['p_donate'];
+			$shared['donate'] = $session['p_donate'];
+		}
+
+		//Add link when available
+		if (!empty($session['p_link'])) {
+			$description .= "\n\n".'Site :'."\n".$session['p_link'];
+			$shared['link'] = $session['p_link'];
+		}
+
+		//Add profile when available
+		if (!empty($session['p_profile'])) {
+			$description .= "\n\n".'Réseau social :'."\n".$session['p_profile'];
+			$shared['profile'] = $session['p_profile'];
+		}
+
+		//Set properties
+		$properties = new EventExtendedProperties(
+			[
+				//Set private property
+				'private' => $private,
+				//Set shared property
+				'shared' => $shared
+			]
+		);
+
+		//Without event
+		if ($event === null) {
+			//Init event
+			$event = new Event(
+				[
+					//Id must match /^[a-v0-9]{5,}$/
+					//XXX: see https://developers.google.com/calendar/api/v3/reference/events/insert#id
+					'id' => $this->prefix.$session['id'],
+					'summary' => $source->getTitle(),
+					'description' => $description,
+					'status' => empty($session['a_canceled'])?'confirmed':'cancelled',
+					'location' => implode(' ', [$session['l_address'], $session['l_zipcode'], $session['l_city']]),
+					'source' => $source,
+					'extendedProperties' => $properties,
+					//TODO: colorId ?
+					//TODO: attendees[] ?
+					'start' => [
+						'dateTime' => $session['start']->format(\DateTime::ISO8601)
+					],
+					'end' => [
+						'dateTime' => $session['stop']->format(\DateTime::ISO8601)
+					]
+				]
+			);
+		//With event
+		} else {
+			//Set summary
+			$event->setSummary($source->getTitle());
+
+			//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->getTitle());
+
+			//Update source url
+			#$eventSource->setUrl($source->getUrl());
+
+			//Set source
+			$event->setSource($source);
+
+			//Get extended properties
+			#$extendedProperties = $event->getExtendedProperties();
+
+			//Update private
+			#$extendedProperties->setPrivate($properties->getPrivate());
+
+			//Update shared
+			#$extendedProperties->setShared($properties->getShared());
+
+			//Set properties
+			$event->setExtendedProperties($properties);
+
+			//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));
+		}
+
+		//Return event
+		return $event;
+	}
+
+	/**
+	 * Insert event
+	 *
+	 * @param string $calendar The calendar mail
+	 * @param array $session The session instance
+	 * @return void
+	 */
+	function insert(string $calendar, array $session): void {
+		//Get event
+		$event = $this->fill($session);
+
+		//Get cache events
+		$cacheEvents = $this->item->get();
+
+		//Insert in cache event
+		$cacheEvents[$session['id']] = $this->service->events->insert($calendar, $event);
+
+		//Set cache events
+		$this->item->set($cacheEvents);
+
+		//Save cache item
+		$this->cache->saveDeferred($this->item);
+	}
+
+	/**
+	 * Update event
+	 *
+	 * @param string $calendar The calendar mail
+	 * @param Event $event The google event instance
+	 * @param array $session The session instance
+	 * @return int The session id
+	 */
+	function update(string $calendar, Event $event, array $session): int {
+		//Get event
+		$event = $this->fill($session, $event);
+
+		//Get cache events
+		$cacheEvents = $this->item->get();
+
+		//Update in cache events
+		$cacheEvents[$session['id']] = $this->service->events->update($calendar, $event->getId(), $event);
+
+		//Set cache events
+		$this->item->set($cacheEvents);
+
+		//Save cache item
+		$this->cache->saveDeferred($this->item);
+
+		//Return session id
+		return $session['id'];
+	}
 }