X-Git-Url: https://git.rapsys.eu/airbundle/blobdiff_plain/4f1511980043fca288c364b794e540af1c54f95e..HEAD:/Command/CalendarCommand.php diff --git a/Command/CalendarCommand.php b/Command/CalendarCommand.php index 25c45ca..f722aae 100644 --- a/Command/CalendarCommand.php +++ b/Command/CalendarCommand.php @@ -1,492 +1,613 @@ - + * + * 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['cache']['directory']); + //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 ($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]); + //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); + } + } + } - //Drop token and report - echo 'Token '.$tokenId.' for calendar '.$token['calendar'].' has expired and is not refreshable'."\n"; + //Get all sessions + $sessions = $this->doctrine->getRepository(Session::class)->findAllByUserIdSynchronized($token['uid']); - //Return failure - //XXX: we want that mail and stop here - return self::FAILURE; + //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(); + } } } - //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 - $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 = '
'.$session['p_class'].'
'.$session['p_contact'].'
'.$session['p_donate'].'
'.$session['p_link'].'
'.$session['p_profile'].'