]> Raphaël G. Git Repositories - airbundle/blobdiff - Command/CalendarCommand.php
Rename rapsysair:calendar2 command to rapsysair:calendar
[airbundle] / Command / CalendarCommand.php
index 2968f4800196059e794d6510c74ed01fff2e6620..f722aaed4cfa842eeb2bf74cedd6ee93a68ea99f 100644 (file)
-<?php
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys AirBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
 
 namespace Rapsys\AirBundle\Command;
 
 use Doctrine\Persistence\ManagerRegistry;
-use Symfony\Component\Cache\Adapter\FilesystemAdapter;
-use Symfony\Component\Console\Command\Command;
+
+use Google\Client;
+use Google\Service\Calendar;
+use Google\Service\Calendar\Event;
+use Google\Service\Calendar\EventExtendedProperties;
+use Google\Service\Calendar\EventSource;
+
 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 Symfony\Contracts\Cache\CacheInterface;
+use Symfony\Contracts\Cache\ItemInterface;
+use Symfony\Contracts\Translation\TranslatorInterface;
+
 use Twig\Extra\Markdown\DefaultMarkdown;
 
+use Rapsys\AirBundle\Command;
+use Rapsys\AirBundle\Entity\GoogleCalendar;
+use Rapsys\AirBundle\Entity\GoogleToken;
 use Rapsys\AirBundle\Entity\Session;
 
+use Rapsys\PackBundle\Util\SluggerUtil;
+
+/**
+ * {@inheritdoc}
+ *
+ * Synchronize sessions in users' google calendar
+ */
 class CalendarCommand extends Command {
-       //Set failure constant
-       const FAILURE = 1;
+       /**
+        * Set description
+        *
+        * Shown with bin/console list
+        */
+       protected string $description = 'Synchronize sessions in users\' calendar';
 
-       ///Set success constant
-       const SUCCESS = 0;
+       /**
+        * Set help
+        *
+        * Shown with bin/console --help rapsysair:calendar
+        */
+       protected string $help = 'This command synchronize sessions in users\' google calendar';
 
-       ///Config array
-       protected $config;
+       /**
+        * Set domain
+        */
+       protected string $domain;
 
        /**
-        * Doctrine instance
+        * Set item
         *
-        * @var ManagerRegistry
+        * Cache item instance
         */
-       protected $doctrine;
+       protected ItemInterface $item;
 
-       ///Locale
-       protected $locale;
+       /**
+        * Set prefix
+        */
+       protected string $prefix;
 
-       ///Translator instance
-       protected $translator;
+       /**
+        * Set service
+        *
+        * Google calendar instance
+        */
+       protected Calendar $service;
 
        /**
-        * Inject doctrine, container and translator interface
+        * {@inheritdoc}
         *
-        * @param ContainerInterface $container The container instance
-        * @param ManagerRegistry $doctrine The doctrine instance
-        * @param RouterInterface $router The router instance
-        * @param TranslatorInterface $translator The translator instance
+        * @param CacheInterface $cache The cache instance
+        * @param Client $google The google client instance
+        * @param DefaultMarkdown $markdown The markdown instance
         */
-    public function __construct(ContainerInterface $container, ManagerRegistry $doctrine, RouterInterface $router, TranslatorInterface $translator) {
+       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();
+               parent::__construct($this->doctrine, $this->locale, $this->router, $this->slugger, $this->translator);
 
-               //Retrieve config
-               $this->config = $container->getParameter($this->getAlias());
+               //Replace google client redirect uri
+               $this->google->setRedirectUri($this->router->generate($this->google->getRedirectUri(), [], UrlGeneratorInterface::ABSOLUTE_URL));
+       }
 
-               //Retrieve locale
-               $this->locale = $container->getParameter('kernel.default_locale');
+       /**
+        * Process the attribution
+        */
+       protected function execute(InputInterface $input, OutputInterface $output): int {
+               //Get domain
+               $this->domain = $this->router->getContext()->getHost();
 
-               //Store doctrine
-        $this->doctrine = $doctrine;
+               //Get manager
+               $manager = $this->doctrine->getManager();
 
-               //Store router
-        $this->router = $router;
+               //Convert from any to latin, then to ascii and lowercase
+               $trans = \Transliterator::create('Any-Latin; Latin-ASCII; Lower()');
 
-               //Get router context
-               $context = $this->router->getContext();
+               //Replace every non alphanumeric character by dash then trim dash
+               $this->prefix = preg_replace(['/(\.[a-z0-9]+)+$/', '/[^a-v0-9]+/'], '', $trans->transliterate($this->domain));
 
-               //Set host
-               $context->setHost('airlibre.eu');
+               //With too short prefix
+               if ($this->prefix === null || strlen($this->prefix) < 4) {
+                       //Throw domain exception
+                       throw new \DomainException('Prefix too short: '.$this->prefix);
+               }
 
-               //Set scheme
-               $context->setScheme('https');
+               //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;
+                               }
+                       }
 
-               //Set the translator
-               $this->translator = $translator;
-       }
+                       //Get google calendar service
+                       $this->service = new Calendar($this->google);
 
-       ///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');
-       }
+                       //Iterate on google calendars
+                       foreach($calendars = $token['calendars'] as $cid => $calendar) {
+                               //Set start
+                               $synchronized = null;
 
-       ///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')
-               );
+                               //Set cache key
+                               $cacheKey = 'command.calendar.'.$this->slugger->short($calendar['mail']);
 
-               //Retrieve events to update
-               $sessions = $this->doctrine->getRepository(Session::class)->fetchAllByDatePeriod($period, $this->locale);
+                               //XXX: TODO: remove DEBUG
+                               #$this->cache->delete($cacheKey);
 
-               //Markdown converted instance
-               $markdown = new DefaultMarkdown;
+                               //Retrieve calendar events
+                               try {
+                                       //Get events
+                                       $events = $this->cache->get(
+                                               //Cache key
+                                               //XXX: set to command.calendar.$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()?:'');
 
-               //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['path']['cache']);
+                                               //TODO: warn user by mail ?
 
-               //Retrieve calendars
-               $cacheCalendars = $cache->getItem('calendars');
+                                               //Skip to next token
+                                               continue;
+                                       }
 
-               //Without calendars
-               if (!$cacheCalendars->isHit()) {
-                       //Return failure
-                       return self::FAILURE;
-               }
+                                       //Throw error
+                                       throw new \LogicException('Calendar event list failed', 0, $e);
+                               }
 
-               //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 (($refreshToken = $googleClient->getRefreshToken()) && ($googleToken = $googleClient->fetchAccessTokenWithRefreshToken($refreshToken)) && empty($googleToken['error'])) {
-                                               //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]);
+                               //Store cache item
+                               $this->item = $this->cache->getItem($cacheKey);
+
+                               //Iterate on sessions to update
+                               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);
+                                               }
+                                       }
+                               }
 
-                                               //Save calendars
-                                               $cacheCalendars->set($calendars);
+                               //Get all sessions
+                               $sessions = $this->doctrine->getRepository(Session::class)->findAllByUserIdSynchronized($token['uid']);
 
-                                               //Save calendar
-                                               $cache->save($cacheCalendars);
+                               //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]);
+                                               }
+                                       }
+                               }
 
-                                               //Drop token and report
-                                               //XXX: submit app to avoid expiration
-                                               //XXX: see https://console.cloud.google.com/apis/credentials/consent?project=calendar-317315
-                                               echo 'Token '.$tokenId.' for calendar '.$token['calendar'].' has expired and is not refreshable'."\n";
+                               //Persist cache item
+                               $this->cache->commit();
 
-                                               //Return failure
-                                               //XXX: we want that mail and stop here
-                                               return self::FAILURE;
-                                       }
+                               //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();
                                }
                        }
                }
 
-               //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']
-                                       ]
-                               );
+               //Return success
+               return self::SUCCESS;
+       }
 
-                               //With expired token
-                               if ($exp = $googleClient->isAccessTokenExpired()) {
-                                       //Last chance to skip this run
-                                       continue;
-                               }
+       /**
+        * 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 google calendar
-                               $googleCalendar = new \Google\Service\Calendar($googleClient);
+               //Get event id
+               $eid = $event->getId();
 
-                               //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";
+               //Delete the event
+               $this->service->events->delete($calendar, $eid);
 
-                                       //Return failure
-                                       return self::FAILURE;
-                               }
+               //Set sid
+               $sid = intval(substr($event->getId(), strlen($this->prefix)));
 
-                               //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";*/
-                                               }
-                                       }
+               //Remove from events and cache events
+               unset($cacheEvents[$sid]);
 
-                                       //Get page token
-                                       $pageToken = $googleEvents->getNextPageToken();
+               //Set cache events
+               $this->item->set($cacheEvents);
 
-                                       //Handle next page
-                                       if ($pageToken) {
-                                               //Replace collection with next one
-                                               $googleEvents = $service->events->listEvents($token['calendar'], $filters+['pageToken' => $pageToken]);
-                                       } else {
-                                               break;
-                                       }
-                               }
+               //Save cache item
+               $this->cache->saveDeferred($this->item);
 
-                               //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
-                                       //TODO: drop shared as unused ???
-                                       $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 = 'Description :'."\n".strip_tags(preg_replace('!<a href="([^"]+)"(?: title="[^"]+")?'.'>([^<]+)</a>!', '\1', $markdown->convert(strip_tags($session['p_description']))));
-                                       $shared['description'] = $markdown->convert(strip_tags($session['p_description']));
-
-                                       //Add class when available
-                                       if (!empty($session['p_class'])) {
-                                               $shared['class'] = $session['p_class'];
-                                               $description .= "\n\n".'Classe :'."\n".$session['p_class'];
-                                       }
+               //Return session id
+               return $sid;
+       }
 
-                                       //Add contact when available
-                                       if (!empty($session['p_contact'])) {
-                                               $shared['contact'] = $session['p_contact'];
-                                               $description .= "\n\n".'Contact :'."\n".$session['p_contact'];
-                                       }
+       /**
+        * 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)
+                       ]
+               );
 
-                                       //Add donate when available
-                                       if (!empty($session['p_donate'])) {
-                                               $shared['donate'] = $session['p_donate'];
-                                               $description .= "\n\n".'Contribuer :'."\n".$session['p_donate'];
-                                       }
+               //Init location
+               $description = 'Emplacement :'."\n".$this->translator->trans($session['l_description']);
+               $shared['location'] = strip_tags($this->translator->trans($session['l_description']));
 
-                                       //Add link when available
-                                       if (!empty($session['p_link'])) {
-                                               $shared['link'] = $session['p_link'];
-                                               $description .= "\n\n".'Site :'."\n".$session['p_link'];
-                                       }
+               //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 profile when available
-                                       if (!empty($session['p_profile'])) {
-                                               $shared['profile'] = $session['p_profile'];
-                                               $description .= "\n\n".'Réseau social :'."\n".$session['p_profile'];
-                                       }
+               //Add class when available
+               if (!empty($session['p_class'])) {
+                       $description .= "\n\n".'Classe :'."\n".$session['p_class'];
+                       $shared['class'] = $session['p_class'];
+               }
 
-                                       //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_title']),
-                                                               #'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];
+               //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'];
+               }
 
-                                               //With updated event
-                                               if ($session['updated'] >= (new \DateTime($event->getUpdated()))) {
-                                                       //Set summary
-                                                       $event->setSummary($session['au_pseudonym'].' '.$this->translator->trans('at '.$session['l_title']));
+               //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 description
-                                                       $event->setDescription($description);
+               //Set properties
+               $properties = new EventExtendedProperties(
+                       [
+                               //Set private property
+                               'private' => $private,
+                               //Set shared property
+                               'shared' => $shared
+                       ]
+               );
 
-                                                       //Set status
-                                                       $event->setStatus(empty($session['a_canceled'])?'confirmed':'cancelled');
+               //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 location
-                                                       $event->setLocation(implode(' ', [$session['l_address'], $session['l_zipcode'], $session['l_city']]));
+                       //Set description
+                       $event->setDescription($description);
 
-                                                       //Get source
-                                                       $eventSource = $event->getSource();
+                       //Set status
+                       $event->setStatus(empty($session['a_canceled'])?'confirmed':'cancelled');
 
-                                                       //Update source title
-                                                       $eventSource->setTitle($source['title']);
+                       //Set location
+                       $event->setLocation(implode(' ', [$session['l_address'], $session['l_zipcode'], $session['l_city']]));
 
-                                                       //Update source url
-                                                       $eventSource->setUrl($source['url']);
+                       //Get source
+                       #$eventSource = $event->getSource();
 
-                                                       //Set source
-                                                       #$event->setSource($source);
+                       //Update source title
+                       #$eventSource->setTitle($source->getTitle());
 
-                                                       //Get extended properties
-                                                       $extendedProperties = $event->getExtendedProperties();
+                       //Update source url
+                       #$eventSource->setUrl($source->getUrl());
 
-                                                       //Update shared
-                                                       $extendedProperties->setShared($shared);
+                       //Set source
+                       $event->setSource($source);
 
-                                                       //TODO: colorId ?
-                                                       //TODO: attendees[] ?
+                       //Get extended properties
+                       #$extendedProperties = $event->getExtendedProperties();
 
-                                                       //Set start
-                                                       $start = $event->getStart();
+                       //Update private
+                       #$extendedProperties->setPrivate($properties->getPrivate());
 
-                                                       //Update start datetime
-                                                       $start->setDateTime($session['start']->format(\DateTime::ISO8601));
+                       //Update shared
+                       #$extendedProperties->setShared($properties->getShared());
 
-                                                       //Set end
-                                                       $end = $event->getEnd();
+                       //Set properties
+                       $event->setExtendedProperties($properties);
 
-                                                       //Update stop datetime
-                                                       $end->setDateTime($session['stop']->format(\DateTime::ISO8601));
+                       //TODO: colorId ?
+                       //TODO: attendees[] ?
 
-                                                       try {
-                                                               //Update 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";
+                       //Set start
+                       $start = $event->getStart();
 
-                                                               //Return failure
-                                                               return self::FAILURE;
-                                                       }
-                                               }
+                       //Update start datetime
+                       $start->setDateTime($session['start']->format(\DateTime::ISO8601));
 
-                                               //Drop from events array
-                                               unset($events[$sessionId]);
-                                       }
-                               }
+                       //Set end
+                       $end = $event->getEnd();
 
-                               //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;
-                                               }
-                                       }
-                               }
-                       }
+                       //Update stop datetime
+                       $end->setDateTime($session['stop']->format(\DateTime::ISO8601));
                }
 
-               //Return success
-               return self::SUCCESS;
+               //Return event
+               return $event;
        }
 
        /**
-        * Return the bundle alias
+        * Insert event
         *
-        * {@inheritdoc}
+        * @param string $calendar The calendar mail
+        * @param array $session The session instance
+        * @return void
         */
-       public function getAlias(): string {
-               return 'rapsys_air';
+       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'];
        }
 }