1 <?php 
declare(strict_types
=1); 
   4  * This file is part of the Rapsys AirBundle package. 
   6  * (c) Raphaël Gertz <symfony@rapsys.eu> 
   8  * For the full copyright and license information, please view the LICENSE 
   9  * file that was distributed with this source code. 
  12 namespace Rapsys\AirBundle\Command
; 
  14 use Doctrine\Persistence\ManagerRegistry
; 
  17 use Google\Service\Calendar
; 
  18 use Google\Service\Calendar\Event
; 
  19 use Google\Service\Calendar\EventExtendedProperties
; 
  20 use Google\Service\Calendar\EventSource
; 
  22 use Symfony\Component\Console\Input\InputInterface
; 
  23 use Symfony\Component\Console\Output\OutputInterface
; 
  24 use Symfony\Component\Routing\Generator\UrlGeneratorInterface
; 
  25 use Symfony\Component\Routing\RouterInterface
; 
  26 use Symfony\Contracts\Cache\CacheInterface
; 
  27 use Symfony\Contracts\Cache\ItemInterface
; 
  28 use Symfony\Contracts\Translation\TranslatorInterface
; 
  30 use Twig\Extra\Markdown\DefaultMarkdown
; 
  32 use Rapsys\AirBundle\Command
; 
  33 use Rapsys\AirBundle\Entity\GoogleCalendar
; 
  34 use Rapsys\AirBundle\Entity\GoogleToken
; 
  35 use Rapsys\AirBundle\Entity\Session
; 
  37 use Rapsys\PackBundle\Util\SluggerUtil
; 
  42  * Synchronize sessions in users' calendar 
  44 class Calendar2Command 
extends Command 
{ 
  48          * Shown with bin/console list 
  50         protected string $description = 'Synchronize sessions in users\' calendar'; 
  55          * Shown with bin/console --help rapsysair:calendar2 
  57         protected string $help = 'This command synchronize sessions in users\' google calendar'; 
  62         protected string $domain; 
  69         protected ItemInterface 
$item; 
  74         protected string $prefix; 
  79          * Google calendar instance 
  81         protected Calendar 
$service; 
  86          * @param CacheInterface $cache The cache instance 
  87          * @param Client $google The google client instance 
  88          * @param DefaultMarkdown $markdown The markdown instance 
  90         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) { 
  91                 //Call parent constructor 
  92                 parent
::__construct($this->doctrine
, $this->locale
, $this->router
, $this->slugger
, $this->translator
); 
  94                 //Replace google client redirect uri 
  95                 $this->google
->setRedirectUri($this->router
->generate($this->google
->getRedirectUri(), [], UrlGeneratorInterface
::ABSOLUTE_URL
)); 
  99          * Process the attribution 
 101         protected function execute(InputInterface 
$input, OutputInterface 
$output): int { 
 103                 $this->domain 
= $this->router
->getContext()->getHost(); 
 106                 $manager = $this->doctrine
->getManager(); 
 108                 //Convert from any to latin, then to ascii and lowercase 
 109                 $trans = \Transliterator
::create('Any-Latin; Latin-ASCII; Lower()'); 
 111                 //Replace every non alphanumeric character by dash then trim dash 
 112                 $this->prefix 
= preg_replace(['/(\.[a-z0-9]+)+$/', '/[^a-v0-9]+/'], '', $trans->transliterate($this->domain
)); 
 114                 //With too short prefix 
 115                 if ($this->prefix 
=== null || strlen($this->prefix
) < 4) { 
 116                         //Throw domain exception 
 117                         throw new \
DomainException('Prefix too short: '.$this->prefix
); 
 120                 //Iterate on google tokens 
 121                 foreach($tokens = $this->doctrine
->getRepository(GoogleToken
::class)->findAllIndexed() as $tid => $token) { 
 122                         //Clear client cache before changing access token 
 123                         //TODO: set a per token cache ? 
 124                         $this->google
->getCache()->clear(); 
 127                         $this->google
->setAccessToken( 
 129                                         'access_token' => $token['access'], 
 130                                         'refresh_token' => $token['refresh'], 
 131                                         'created' => $token['created']->getTimestamp(), 
 132                                         'expires_in' => $token['expired']->getTimestamp() - (new \
DateTime('now'))->getTimestamp() 
 137                         if ($this->google
->isAccessTokenExpired()) { 
 139                                 if (($gRefresh = $this->google
->getRefreshToken()) && ($gToken = $this->google
->fetchAccessTokenWithRefreshToken($gRefresh)) && empty($gToken['error'])) { 
 141                                         $googleToken = $this->doctrine
->getRepository(GoogleToken
::class)->findOneById($token['id']); 
 144                                         $googleToken->setAccess($gToken['access_token']); 
 147                                         $googleToken->setExpired(new \
DateTime('+'.$gToken['expires_in'].' second')); 
 150                                         $googleToken->setRefresh($gToken['refresh_token']); 
 152                                         //Queue google token save 
 153                                         $manager->persist($googleToken); 
 155                                         //Flush to get the ids 
 160                                         fprintf(STDERR
, 'Unable to refresh token %d: %s', $token['id'], $gToken['error']?:''); 
 162                                         //TODO: warn user by mail ? 
 169                         //Get google calendar service 
 170                         $this->service 
= new Calendar($this->google
); 
 172                         //Iterate on google calendars 
 173                         foreach($calendars = $token['calendars'] as $cid => $calendar) { 
 175                                 $synchronized = null; 
 178                                 $cacheKey = 'command.calendar2.'.$this->slugger
->short($calendar['mail']); 
 180                                 //XXX: TODO: remove DEBUG 
 181                                 #$this->cache->delete($cacheKey); 
 183                                 //Retrieve calendar events 
 186                                         $events = $this->cache
->get( 
 188                                                 //XXX: set to command.calendar2.$mail 
 190                                                 //Fetch mail calendar event list 
 191                                                 function (ItemInterface 
$item) use ($calendar, &$synchronized): array { 
 193                                                         $item->expiresAfter(3600); 
 196                                                         $synchronized = new \
DateTime('now'); 
 202                                                         //TODO: add a filter to only retrieve 
 204                                                                 //XXX: every event even deleted one to be able to update them 
 205                                                                 'showDeleted' => true, 
 206                                                                 //XXX: every instances 
 207                                                                 'singleEvents' => false, 
 208                                                                 //XXX: select only domain events 
 209                                                                 'privateExtendedProperty' => 'domain='.$this->domain
 
 210                                                                 #TODO: restrict events even more by time or updated datetime ? (-1 week to +2 week) 
 211                                                                 //TODO: fetch events one day before and one day after to avoid triggering double insert duplicate key 409 errors :=) on google 
 212                                                                 #'timeMin' => $period->getStartDate()->format(\DateTime::ISO8601), 
 213                                                                 #'timeMax' => $period->getEndDate()->format(\DateTime::ISO8601) 
 214                                                                 /*, 'iCalUID' => 'airlibre/?????'*//*'orderBy' => 'startTime', */ 
 215                                                                 //updatedMin => new \DateTime('-1 week') ? 
 221                                                         //Iterate until next page token is null 
 223                                                                 //Get calendar events list 
 224                                                                 //XXX: see vendor/google/apiclient-services/src/Calendar/Resource/Events.php +289 
 225                                                                 $eventList = $this->service
->events
->listEvents($calendar['mail'], ['pageToken' => $pageToken]+
$filters); 
 228                                                                 foreach($eventList->getItems() as $event) { 
 229                                                                         //With extended properties 
 230                                                                         if (($properties = $event->getExtendedProperties()) && ($private = $properties->getPrivate()) && isset($private['id']) && ($id = $private['id']) && isset($private['domain']) && $private['domain'] == $this->domain
) { 
 232                                                                                 $events[$id] = $event; 
 233                                                                         //XXX: 3rd party events without matching prefix and id are skipped 
 235                                                                         #       #echo 'Skipping '.$event->getId().':'.$event->getSummary()."\n"; 
 236                                                                         #       echo 'Skipping '.$id.':'.$event->getSummary()."\n"; 
 239                                                         } while ($pageToken = $eventList->getNextPageToken()); 
 246                                 } catch(\Google\Service\Exception 
$e) { 
 248                                         //XXX: see https://cloud.google.com/apis/design/errors 
 249                                         if ($e->getCode() == 401 || $e->getCode() == 403) { 
 251                                                 fprintf(STDERR
, 'Unable to list calendar %d events: %s', $calendar['id'], $e->getMessage()?:''); 
 253                                                 //TODO: warn user by mail ? 
 260                                         throw new \
LogicException('Calendar event list failed', 0, $e); 
 264                                 $this->item 
= $this->cache
->getItem($cacheKey); 
 266                                 //Iterate on sessions to update 
 267                                 foreach($this->doctrine
->getRepository(Session
::class)->findAllByUserIdSynchronized($token['uid'], $calendar['synchronized']) as $session) { 
 268                                         //Start exception catching 
 271                                                 if (!isset($events[$session['id']])) { 
 273                                                         $this->insert($calendar['mail'], $session); 
 274                                                 //With locked session 
 275                                                 } elseif (($event = $events[$session['id']]) && !empty($session['locked'])) { 
 277                                                         $sid = $this->delete($calendar['mail'], $event); 
 279                                                         //Drop from events array 
 280                                                         unset($events[$sid]); 
 281                                                 //With event to update 
 282                                                 } elseif ($session['modified'] > (new \
DateTime($event->getUpdated()))) { 
 284                                                         $sid = $this->update($calendar['mail'], $event, $session); 
 286                                                         //Drop from events array 
 287                                                         unset($events[$sid]); 
 290                                         } catch(\Google\Service\Exception 
$e) { 
 291                                                 //Identifier already exists 
 292                                                 if ($e->getCode() == 409) { 
 294                                                         //XXX: see vendor/google/apiclient-services/src/Calendar/Resource/Events.php +81 
 295                                                         $event = $this->service
->events
->get($calendar['mail'], $this->prefix
.$session['id']); 
 298                                                         if ($session['modified'] > (new \
DateTime($event->getUpdated()))) { 
 300                                                                 $sid = $this->update($calendar['mail'], $event, $session); 
 302                                                                 //Drop from events array 
 303                                                                 unset($events[$sid]); 
 305                                                 //TODO: handle other codes gracefully ? (503 & co) 
 309                                                         throw new \
LogicException(sprintf('Calendar %s event %s operation failed', $calendar, $this->prefix
.$session['id']), 0, $e); 
 315                                 $sessions = $this->doctrine
->getRepository(Session
::class)->findAllByUserIdSynchronized($token['uid']); 
 317                                 //Remaining events to drop 
 318                                 foreach($events as $eid => $event) { 
 319                                         //With events updated since last synchronized 
 320                                         if ($event->getStatus() == 'confirmed' && (new \
DateTime($event->getUpdated())) > $calendar['synchronized']) { 
 321                                                 //TODO: Add a try/catch here to handle error codes gracefully (503 & co) ? 
 322                                                 //With event to update 
 323                                                 if (isset($sessions[$eid]) && ($session = $sessions[$eid]) && empty($session['locked'])) { 
 325                                                         $sid = $this->update($calendar['mail'], $event, $session); 
 327                                                         //Drop from events array 
 328                                                         unset($events[$sid]); 
 329                                                 //With locked or unknown session 
 332                                                         $sid = $this->delete($calendar['mail'], $event); 
 334                                                         //Drop from events array 
 335                                                         unset($events[$sid]); 
 341                                 $this->cache
->commit(); 
 344                                 //XXX: only store synchronized on run without caching 
 346                                         //Get google calendar 
 347                                         $googleCalendar = $this->doctrine
->getRepository(GoogleCalendar
::class)->findOneById($calendar['id']); 
 350                                         $googleCalendar->setSynchronized($synchronized); 
 352                                         //Queue google calendar save 
 353                                         $manager->persist($googleCalendar); 
 355                                         //Flush to get the ids 
 362                 return self
::SUCCESS
; 
 368          * @param string $calendar The calendar mail 
 369          * @param Event $event The google event instance 
 372         function delete(string $calendar, Event 
$event): int { 
 374                 $cacheEvents = $this->item
->get(); 
 377                 $eid = $event->getId(); 
 380                 $this->service
->events
->delete($calendar, $eid); 
 383                 $sid = intval(substr($event->getId(), strlen($this->prefix
))); 
 385                 //Remove from events and cache events 
 386                 unset($cacheEvents[$sid]); 
 389                 $this->item
->set($cacheEvents); 
 392                 $this->cache
->saveDeferred($this->item
); 
 401          * TODO: add domain based/calendar mail specific templates ? 
 403          * @param array $session The session instance 
 404          * @param ?Event $event The event instance 
 405          * @return Event The filled event 
 407         function fill(array $session, ?Event 
$event = null): Event 
{ 
 408                 //Init private properties 
 410                         'id' => $session['id'], 
 411                         'domain' => $this->domain
, 
 412                         'updated' => $session['modified']->format(\DateTime
::ISO8601
) 
 415                 //Init shared properties 
 416                 //TODO: validate for constraints here ??? https://developers.google.com/calendar/api/guides/extended-properties 
 417                 //TODO: drop shared as unused ??? 
 419                         'gps' => $session['l_latitude'].','.$session['l_longitude'] 
 423                 $source = new EventSource( 
 425                                 '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']), 
 426                                 '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
) 
 431                 $description = 'Emplacement :'."\n".$this->translator
->trans($session['l_description']); 
 432                 $shared['location'] = strip_tags($this->translator
->trans($session['l_description'])); 
 434                 //Add description when available 
 435                 if(!empty($session['p_description'])) { 
 436                         $description .= "\n\n".'Description :'."\n".strip_tags(preg_replace('!<a href="([^"]+)"(?: title="[^"]+")?'.'>([^<]+)</a>!', '\1', $this->markdown
->convert(strip_tags($session['p_description'])))); 
 437                         $shared['description'] = $this->markdown
->convert(strip_tags($session['p_description'])); 
 440                 //Add class when available 
 441                 if (!empty($session['p_class'])) { 
 442                         $description .= "\n\n".'Classe :'."\n".$session['p_class']; 
 443                         $shared['class'] = $session['p_class']; 
 446                 //Add contact when available 
 447                 if (!empty($session['p_contact'])) { 
 448                         $description .= "\n\n".'Contact :'."\n".$session['p_contact']; 
 449                         $shared['contact'] = $session['p_contact']; 
 452                 //Add donate when available 
 453                 if (!empty($session['p_donate'])) { 
 454                         $description .= "\n\n".'Contribuer :'."\n".$session['p_donate']; 
 455                         $shared['donate'] = $session['p_donate']; 
 458                 //Add link when available 
 459                 if (!empty($session['p_link'])) { 
 460                         $description .= "\n\n".'Site :'."\n".$session['p_link']; 
 461                         $shared['link'] = $session['p_link']; 
 464                 //Add profile when available 
 465                 if (!empty($session['p_profile'])) { 
 466                         $description .= "\n\n".'Réseau social :'."\n".$session['p_profile']; 
 467                         $shared['profile'] = $session['p_profile']; 
 471                 $properties = new EventExtendedProperties( 
 473                                 //Set private property 
 474                                 'private' => $private, 
 475                                 //Set shared property 
 481                 if ($event === null) { 
 485                                         //Id must match /^[a-v0-9]{5,}$/ 
 486                                         //XXX: see https://developers.google.com/calendar/api/v3/reference/events/insert#id 
 487                                         'id' => $this->prefix
.$session['id'], 
 488                                         'summary' => $source->getTitle(), 
 489                                         'description' => $description, 
 490                                         'status' => empty($session['a_canceled'])?'confirmed':'cancelled', 
 491                                         'location' => implode(' ', [$session['l_address'], $session['l_zipcode'], $session['l_city']]), 
 493                                         'extendedProperties' => $properties, 
 495                                         //TODO: attendees[] ? 
 497                                                 'dateTime' => $session['start']->format(\DateTime
::ISO8601
) 
 500                                                 'dateTime' => $session['stop']->format(\DateTime
::ISO8601
) 
 507                         $event->setSummary($source->getTitle()); 
 510                         $event->setDescription($description); 
 513                         $event->setStatus(empty($session['a_canceled'])?'confirmed':'cancelled'); 
 516                         $event->setLocation(implode(' ', [$session['l_address'], $session['l_zipcode'], $session['l_city']])); 
 519                         #$eventSource = $event->getSource(); 
 521                         //Update source title 
 522                         #$eventSource->setTitle($source->getTitle()); 
 525                         #$eventSource->setUrl($source->getUrl()); 
 528                         $event->setSource($source); 
 530                         //Get extended properties 
 531                         #$extendedProperties = $event->getExtendedProperties(); 
 534                         #$extendedProperties->setPrivate($properties->getPrivate()); 
 537                         #$extendedProperties->setShared($properties->getShared()); 
 540                         $event->setExtendedProperties($properties); 
 543                         //TODO: attendees[] ? 
 546                         $start = $event->getStart(); 
 548                         //Update start datetime 
 549                         $start->setDateTime($session['start']->format(\DateTime
::ISO8601
)); 
 552                         $end = $event->getEnd(); 
 554                         //Update stop datetime 
 555                         $end->setDateTime($session['stop']->format(\DateTime
::ISO8601
)); 
 565          * @param string $calendar The calendar mail 
 566          * @param array $session The session instance 
 569         function insert(string $calendar, array $session): void { 
 571                 $event = $this->fill($session); 
 574                 $cacheEvents = $this->item
->get(); 
 576                 //Insert in cache event 
 577                 $cacheEvents[$session['id']] = $this->service
->events
->insert($calendar, $event); 
 580                 $this->item
->set($cacheEvents); 
 583                 $this->cache
->saveDeferred($this->item
); 
 589          * @param string $calendar The calendar mail 
 590          * @param Event $event The google event instance 
 591          * @param array $session The session instance 
 592          * @return int The session id 
 594         function update(string $calendar, Event 
$event, array $session): int { 
 596                 $event = $this->fill($session, $event); 
 599                 $cacheEvents = $this->item
->get(); 
 601                 //Update in cache events 
 602                 $cacheEvents[$session['id']] = $this->service
->events
->update($calendar, $event->getId(), $event); 
 605                 $this->item
->set($cacheEvents); 
 608                 $this->cache
->saveDeferred($this->item
); 
 611                 return $session['id'];