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 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.calendar.'.$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.calendar.$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'];