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' google calendar 
  44 class CalendarCommand 
extends Command 
{ 
  48          * Shown with bin/console list 
  50         protected string $description = 'Synchronize sessions in users\' calendar'; 
  55          * Shown with bin/console --help rapsysair:calendar 
  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                                 //TODO: better handle internal_failure 
 140                                 if (($gRefresh = $this->google
->getRefreshToken()) && ($gToken = $this->google
->fetchAccessTokenWithRefreshToken($gRefresh)) && empty($gToken['error'])) { 
 142                                         $googleToken = $this->doctrine
->getRepository(GoogleToken
::class)->findOneById($token['id']); 
 145                                         $googleToken->setAccess($gToken['access_token']); 
 148                                         $googleToken->setExpired(new \
DateTime('+'.$gToken['expires_in'].' second')); 
 151                                         $googleToken->setRefresh($gToken['refresh_token']); 
 153                                         //Queue google token save 
 154                                         $manager->persist($googleToken); 
 156                                         //Flush to get the ids 
 161                                         //TODO: remove that and simply log internal failure ? 
 162                                         fprintf(STDERR
, 'Unable to refresh token %d: %s', $token['id'], $gToken['error']?:''); 
 164                                         //TODO: warn user by mail ? 
 171                         //Get google calendar service 
 172                         $this->service 
= new Calendar($this->google
); 
 174                         //Iterate on google calendars 
 175                         foreach($calendars = $token['calendars'] as $cid => $calendar) { 
 177                                 $synchronized = null; 
 180                                 $cacheKey = 'command.calendar.'.$this->slugger
->short($calendar['mail']); 
 182                                 //XXX: TODO: remove DEBUG 
 183                                 #$this->cache->delete($cacheKey); 
 185                                 //Retrieve calendar events 
 188                                         $events = $this->cache
->get( 
 190                                                 //XXX: set to command.calendar.$mail 
 192                                                 //Fetch mail calendar event list 
 193                                                 function (ItemInterface 
$item) use ($calendar, &$synchronized): array { 
 195                                                         $item->expiresAfter(3600); 
 198                                                         $synchronized = new \
DateTime('now'); 
 204                                                         //TODO: add a filter to only retrieve 
 206                                                                 //XXX: every event even deleted one to be able to update them 
 207                                                                 'showDeleted' => true, 
 208                                                                 //XXX: every instances 
 209                                                                 'singleEvents' => false, 
 210                                                                 //XXX: select only domain events 
 211                                                                 'privateExtendedProperty' => 'domain='.$this->domain
 
 212                                                                 #TODO: restrict events even more by time or updated datetime ? (-1 week to +2 week) 
 213                                                                 //TODO: fetch events one day before and one day after to avoid triggering double insert duplicate key 409 errors :=) on google 
 214                                                                 #'timeMin' => $period->getStartDate()->format(\DateTime::ISO8601), 
 215                                                                 #'timeMax' => $period->getEndDate()->format(\DateTime::ISO8601) 
 216                                                                 /*, 'iCalUID' => 'airlibre/?????'*//*'orderBy' => 'startTime', */ 
 217                                                                 //updatedMin => new \DateTime('-1 week') ? 
 223                                                         //Iterate until next page token is null 
 225                                                                 //Get calendar events list 
 226                                                                 //XXX: see vendor/google/apiclient-services/src/Calendar/Resource/Events.php +289 
 227                                                                 $eventList = $this->service
->events
->listEvents($calendar['mail'], ['pageToken' => $pageToken]+
$filters); 
 230                                                                 foreach($eventList->getItems() as $event) { 
 231                                                                         //With extended properties 
 232                                                                         if (($properties = $event->getExtendedProperties()) && ($private = $properties->getPrivate()) && isset($private['id']) && ($id = $private['id']) && isset($private['domain']) && $private['domain'] == $this->domain
) { 
 234                                                                                 $events[$id] = $event; 
 235                                                                         //XXX: 3rd party events without matching prefix and id are skipped 
 237                                                                         #       #echo 'Skipping '.$event->getId().':'.$event->getSummary()."\n"; 
 238                                                                         #       echo 'Skipping '.$id.':'.$event->getSummary()."\n"; 
 241                                                         } while ($pageToken = $eventList->getNextPageToken()); 
 248                                 } catch(\Google\Service\Exception 
$e) { 
 250                                         //XXX: see https://cloud.google.com/apis/design/errors 
 251                                         if ($e->getCode() == 401 || $e->getCode() == 403) { 
 253                                                 fprintf(STDERR
, 'Unable to list calendar %d events: %s', $calendar['id'], $e->getMessage()?:''); 
 255                                                 //TODO: warn user by mail ? 
 262                                         throw new \
LogicException('Calendar event list failed', 0, $e); 
 266                                 $this->item 
= $this->cache
->getItem($cacheKey); 
 268                                 //Iterate on sessions to update 
 269                                 foreach($this->doctrine
->getRepository(Session
::class)->findAllByUserIdSynchronized($token['uid'], $calendar['synchronized']) as $session) { 
 270                                         //Start exception catching 
 273                                                 if (!isset($events[$session['id']])) { 
 275                                                         $this->insert($calendar['mail'], $session); 
 276                                                 //With locked session 
 277                                                 } elseif (($event = $events[$session['id']]) && !empty($session['locked'])) { 
 279                                                         $sid = $this->delete($calendar['mail'], $event); 
 281                                                         //Drop from events array 
 282                                                         unset($events[$sid]); 
 283                                                 //With event to update 
 284                                                 } elseif ($session['modified'] > (new \
DateTime($event->getUpdated()))) { 
 286                                                         $sid = $this->update($calendar['mail'], $event, $session); 
 288                                                         //Drop from events array 
 289                                                         unset($events[$sid]); 
 292                                         } catch(\Google\Service\Exception 
$e) { 
 293                                                 //Identifier already exists 
 294                                                 if ($e->getCode() == 409) { 
 296                                                         //XXX: see vendor/google/apiclient-services/src/Calendar/Resource/Events.php +81 
 297                                                         $event = $this->service
->events
->get($calendar['mail'], $this->prefix
.$session['id']); 
 300                                                         if ($session['modified'] > (new \
DateTime($event->getUpdated()))) { 
 302                                                                 $sid = $this->update($calendar['mail'], $event, $session); 
 304                                                                 //Drop from events array 
 305                                                                 unset($events[$sid]); 
 307                                                 //TODO: handle other codes gracefully ? (503 & co) 
 311                                                         throw new \
LogicException(sprintf('Calendar %s event %s operation failed', $calendar, $this->prefix
.$session['id']), 0, $e); 
 320                                 $sessions = $this->doctrine
->getRepository(Session
::class)->findAllByUserIdSynchronized($token['uid']); 
 322                                 //Remaining events to drop 
 323                                 foreach($events as $eid => $event) { 
 324                                         //With events updated since last synchronized 
 325                                         if ($event->getStatus() == 'confirmed' && (new \
DateTime($event->getUpdated())) > $calendar['synchronized']) { 
 326                                                 //TODO: Add a try/catch here to handle error codes gracefully (503 & co) ? 
 327                                                 //With event to update 
 328                                                 if (isset($sessions[$eid]) && ($session = $sessions[$eid]) && empty($session['locked'])) { 
 330                                                         $sid = $this->update($calendar['mail'], $event, $session); 
 332                                                         //Drop from events array 
 333                                                         unset($events[$sid]); 
 334                                                 //With locked or unknown session 
 337                                                         $sid = $this->delete($calendar['mail'], $event); 
 339                                                         //Drop from events array 
 340                                                         unset($events[$sid]); 
 346                                 $this->cache
->commit(); 
 349                                 //XXX: only store synchronized on run without caching 
 351                                         //Get google calendar 
 352                                         $googleCalendar = $this->doctrine
->getRepository(GoogleCalendar
::class)->findOneById($calendar['id']); 
 355                                         $googleCalendar->setSynchronized($synchronized); 
 357                                         //Queue google calendar save 
 358                                         $manager->persist($googleCalendar); 
 360                                         //Flush to get the ids 
 367                 return self
::SUCCESS
; 
 373          * @param string $calendar The calendar mail 
 374          * @param Event $event The google event instance 
 377         function delete(string $calendar, Event 
$event): int { 
 379                 $cacheEvents = $this->item
->get(); 
 382                 $eid = $event->getId(); 
 385                 $this->service
->events
->delete($calendar, $eid); 
 388                 $sid = intval(substr($event->getId(), strlen($this->prefix
))); 
 390                 //Remove from events and cache events 
 391                 unset($cacheEvents[$sid]); 
 394                 $this->item
->set($cacheEvents); 
 397                 $this->cache
->saveDeferred($this->item
); 
 406          * TODO: add domain based/calendar mail specific templates ? 
 408          * @param array $session The session instance 
 409          * @param ?Event $event The event instance 
 410          * @return Event The filled event 
 412         function fill(array $session, ?Event 
$event = null): Event 
{ 
 413                 //Init private properties 
 415                         'id' => $session['id'], 
 416                         'domain' => $this->domain
, 
 417                         'updated' => $session['modified']->format(\DateTime
::ISO8601
) 
 420                 //Init shared properties 
 421                 //TODO: validate for constraints here ??? https://developers.google.com/calendar/api/guides/extended-properties 
 422                 //TODO: drop shared as unused ??? 
 424                         'gps' => $session['l_latitude'].','.$session['l_longitude'] 
 428                 $source = new EventSource( 
 430                                 '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']), 
 431                                 '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
) 
 436                 $description = 'Emplacement :'."\n".$this->translator
->trans($session['l_description']); 
 437                 $shared['location'] = strip_tags($this->translator
->trans($session['l_description'])); 
 439                 //Add description when available 
 440                 if(!empty($session['p_description'])) { 
 441                         $description .= "\n\n".'Description :'."\n".strip_tags(preg_replace('!<a href="([^"]+)"(?: title="[^"]+")?'.'>([^<]+)</a>!', '\1', $this->markdown
->convert(strip_tags($session['p_description'])))); 
 442                         $shared['description'] = $this->markdown
->convert(strip_tags($session['p_description'])); 
 445                 //Add class when available 
 446                 if (!empty($session['p_class'])) { 
 447                         $description .= "\n\n".'Classe :'."\n".$session['p_class']; 
 448                         $shared['class'] = $session['p_class']; 
 451                 //Add contact when available 
 452                 if (!empty($session['p_contact'])) { 
 453                         $description .= "\n\n".'Contact :'."\n".$session['p_contact']; 
 454                         $shared['contact'] = $session['p_contact']; 
 457                 //Add donate when available 
 458                 if (!empty($session['p_donate'])) { 
 459                         $description .= "\n\n".'Contribuer :'."\n".$session['p_donate']; 
 460                         $shared['donate'] = $session['p_donate']; 
 463                 //Add link when available 
 464                 if (!empty($session['p_link'])) { 
 465                         $description .= "\n\n".'Site :'."\n".$session['p_link']; 
 466                         $shared['link'] = $session['p_link']; 
 469                 //Add profile when available 
 470                 if (!empty($session['p_profile'])) { 
 471                         $description .= "\n\n".'Réseau social :'."\n".$session['p_profile']; 
 472                         $shared['profile'] = $session['p_profile']; 
 476                 $properties = new EventExtendedProperties( 
 478                                 //Set private property 
 479                                 'private' => $private, 
 480                                 //Set shared property 
 486                 if ($event === null) { 
 490                                         //Id must match /^[a-v0-9]{5,}$/ 
 491                                         //XXX: see https://developers.google.com/calendar/api/v3/reference/events/insert#id 
 492                                         'id' => $this->prefix
.$session['id'], 
 493                                         'summary' => $source->getTitle(), 
 494                                         'description' => $description, 
 495                                         'status' => empty($session['a_canceled'])?'confirmed':'cancelled', 
 496                                         'location' => implode(' ', [$session['l_address'], $session['l_zipcode'], $session['l_city']]), 
 498                                         'extendedProperties' => $properties, 
 500                                         //TODO: attendees[] ? 
 502                                                 'dateTime' => $session['start']->format(\DateTime
::ISO8601
) 
 505                                                 'dateTime' => $session['stop']->format(\DateTime
::ISO8601
) 
 512                         $event->setSummary($source->getTitle()); 
 515                         $event->setDescription($description); 
 518                         $event->setStatus(empty($session['a_canceled'])?'confirmed':'cancelled'); 
 521                         $event->setLocation(implode(' ', [$session['l_address'], $session['l_zipcode'], $session['l_city']])); 
 524                         #$eventSource = $event->getSource(); 
 526                         //Update source title 
 527                         #$eventSource->setTitle($source->getTitle()); 
 530                         #$eventSource->setUrl($source->getUrl()); 
 533                         $event->setSource($source); 
 535                         //Get extended properties 
 536                         #$extendedProperties = $event->getExtendedProperties(); 
 539                         #$extendedProperties->setPrivate($properties->getPrivate()); 
 542                         #$extendedProperties->setShared($properties->getShared()); 
 545                         $event->setExtendedProperties($properties); 
 548                         //TODO: attendees[] ? 
 551                         $start = $event->getStart(); 
 553                         //Update start datetime 
 554                         $start->setDateTime($session['start']->format(\DateTime
::ISO8601
)); 
 557                         $end = $event->getEnd(); 
 559                         //Update stop datetime 
 560                         $end->setDateTime($session['stop']->format(\DateTime
::ISO8601
)); 
 570          * @param string $calendar The calendar mail 
 571          * @param array $session The session instance 
 574         function insert(string $calendar, array $session): void { 
 576                 $event = $this->fill($session); 
 579                 $cacheEvents = $this->item
->get(); 
 581                 //Insert in cache event 
 582                 $cacheEvents[$session['id']] = $this->service
->events
->insert($calendar, $event); 
 585                 $this->item
->set($cacheEvents); 
 588                 $this->cache
->saveDeferred($this->item
); 
 594          * @param string $calendar The calendar mail 
 595          * @param Event $event The google event instance 
 596          * @param array $session The session instance 
 597          * @return int The session id 
 599         function update(string $calendar, Event 
$event, array $session): int { 
 601                 $event = $this->fill($session, $event); 
 604                 $cacheEvents = $this->item
->get(); 
 606                 //Update in cache events 
 607                 $cacheEvents[$session['id']] = $this->service
->events
->update($calendar, $event->getId(), $event); 
 610                 $this->item
->set($cacheEvents); 
 613                 $this->cache
->saveDeferred($this->item
); 
 616                 return $session['id'];