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'];