-<?php declare(strict_types=1);
-
-/*
- * This file is part of the Rapsys AirBundle package.
- *
- * (c) Raphaël Gertz <symfony@rapsys.eu>
- *
- * 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 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\Routing\Generator\UrlGeneratorInterface;
-use Symfony\Component\Routing\RouterInterface;
-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' calendar
- */
-class Calendar2Command extends Command {
- /**
- * Set description
- *
- * Shown with bin/console list
- */
- protected string $description = 'Synchronize sessions in users\' calendar';
-
- /**
- * Set help
- *
- * Shown with bin/console --help rapsysair:calendar2
- */
- protected string $help = 'This command synchronize sessions in users\' google calendar';
-
- /**
- * Set domain
- */
- protected string $domain;
-
- /**
- * Set item
- *
- * Cache item instance
- */
- protected ItemInterface $item;
-
- /**
- * Set prefix
- */
- protected string $prefix;
-
- /**
- * Set service
- *
- * Google calendar instance
- */
- protected Calendar $service;
-
- /**
- * {@inheritdoc}
- *
- * @param CacheInterface $cache The cache instance
- * @param Client $google The google client instance
- * @param DefaultMarkdown $markdown The markdown instance
- */
- 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($this->doctrine, $this->locale, $this->router, $this->slugger, $this->translator);
-
- //Replace google client redirect uri
- $this->google->setRedirectUri($this->router->generate($this->google->getRedirectUri(), [], UrlGeneratorInterface::ABSOLUTE_URL));
- }
-
- /**
- * Process the attribution
- */
- protected function execute(InputInterface $input, OutputInterface $output): int {
- //Get domain
- $this->domain = $this->router->getContext()->getHost();
-
- //Get manager
- $manager = $this->doctrine->getManager();
-
- //Convert from any to latin, then to ascii and lowercase
- $trans = \Transliterator::create('Any-Latin; Latin-ASCII; Lower()');
-
- //Replace every non alphanumeric character by dash then trim dash
- $this->prefix = preg_replace(['/(\.[a-z0-9]+)+$/', '/[^a-v0-9]+/'], '', $trans->transliterate($this->domain));
-
- //With too short prefix
- if ($this->prefix === null || strlen($this->prefix) < 4) {
- //Throw domain exception
- throw new \DomainException('Prefix too short: '.$this->prefix);
- }
-
- //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;
- }
- }
-
- //Get google calendar service
- $this->service = new Calendar($this->google);
-
- //Iterate on google calendars
- foreach($calendars = $token['calendars'] as $cid => $calendar) {
- //Set start
- $synchronized = null;
-
- //Set cache key
- $cacheKey = 'command.calendar2.'.$this->slugger->short($calendar['mail']);
-
- //XXX: TODO: remove DEBUG
- #$this->cache->delete($cacheKey);
-
- //Retrieve calendar events
- try {
- //Get events
- $events = $this->cache->get(
- //Cache key
- //XXX: set to command.calendar2.$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()?:'');
-
- //TODO: warn user by mail ?
-
- //Skip to next token
- continue;
- }
-
- //Throw error
- throw new \LogicException('Calendar event list failed', 0, $e);
- }
-
- //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);
- }
- }
- }
-
- //Get all sessions
- $sessions = $this->doctrine->getRepository(Session::class)->findAllByUserIdSynchronized($token['uid']);
-
- //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();
- }
- }
- }
-
- //Return success
- return self::SUCCESS;
- }
-
- /**
- * 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 event id
- $eid = $event->getId();
-
- //Delete the event
- $this->service->events->delete($calendar, $eid);
-
- //Set sid
- $sid = intval(substr($event->getId(), strlen($this->prefix)));
-
- //Remove from events and cache events
- unset($cacheEvents[$sid]);
-
- //Set cache events
- $this->item->set($cacheEvents);
-
- //Save cache item
- $this->cache->saveDeferred($this->item);
-
- //Return session id
- return $sid;
- }
-
- /**
- * Fill event
- *
- * TODO: add domain based/calendar mail specific templates ?
- *
- * @param array $session The session instance
- * @param ?Event $event The event instance
- * @return Event The filled event
- */
- function fill(array $session, ?Event $event = null): Event {
- //Init private properties
- $private = [
- 'id' => $session['id'],
- 'domain' => $this->domain,
- 'updated' => $session['modified']->format(\DateTime::ISO8601)
- ];
-
- //Init shared properties
- //TODO: validate for constraints here ??? https://developers.google.com/calendar/api/guides/extended-properties
- //TODO: drop shared as unused ???
- $shared = [
- 'gps' => $session['l_latitude'].','.$session['l_longitude']
- ];
-
- //Init source
- $source = new EventSource(
- [
- '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']),
- '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)
- ]
- );
-
- //Init location
- $description = 'Emplacement :'."\n".$this->translator->trans($session['l_description']);
- $shared['location'] = strip_tags($this->translator->trans($session['l_description']));
-
- //Add description when available
- if(!empty($session['p_description'])) {
- $description .= "\n\n".'Description :'."\n".strip_tags(preg_replace('!<a href="([^"]+)"(?: title="[^"]+")?'.'>([^<]+)</a>!', '\1', $this->markdown->convert(strip_tags($session['p_description']))));
- $shared['description'] = $this->markdown->convert(strip_tags($session['p_description']));
- }
-
- //Add class when available
- if (!empty($session['p_class'])) {
- $description .= "\n\n".'Classe :'."\n".$session['p_class'];
- $shared['class'] = $session['p_class'];
- }
-
- //Add contact when available
- if (!empty($session['p_contact'])) {
- $description .= "\n\n".'Contact :'."\n".$session['p_contact'];
- $shared['contact'] = $session['p_contact'];
- }
-
- //Add donate when available
- if (!empty($session['p_donate'])) {
- $description .= "\n\n".'Contribuer :'."\n".$session['p_donate'];
- $shared['donate'] = $session['p_donate'];
- }
-
- //Add link when available
- if (!empty($session['p_link'])) {
- $description .= "\n\n".'Site :'."\n".$session['p_link'];
- $shared['link'] = $session['p_link'];
- }
-
- //Add profile when available
- if (!empty($session['p_profile'])) {
- $description .= "\n\n".'Réseau social :'."\n".$session['p_profile'];
- $shared['profile'] = $session['p_profile'];
- }
-
- //Set properties
- $properties = new EventExtendedProperties(
- [
- //Set private property
- 'private' => $private,
- //Set shared property
- 'shared' => $shared
- ]
- );
-
- //Without event
- if ($event === null) {
- //Init event
- $event = new Event(
- [
- //Id must match /^[a-v0-9]{5,}$/
- //XXX: see https://developers.google.com/calendar/api/v3/reference/events/insert#id
- 'id' => $this->prefix.$session['id'],
- 'summary' => $source->getTitle(),
- 'description' => $description,
- 'status' => empty($session['a_canceled'])?'confirmed':'cancelled',
- 'location' => implode(' ', [$session['l_address'], $session['l_zipcode'], $session['l_city']]),
- 'source' => $source,
- 'extendedProperties' => $properties,
- //TODO: colorId ?
- //TODO: attendees[] ?
- 'start' => [
- 'dateTime' => $session['start']->format(\DateTime::ISO8601)
- ],
- 'end' => [
- 'dateTime' => $session['stop']->format(\DateTime::ISO8601)
- ]
- ]
- );
- //With event
- } else {
- //Set summary
- $event->setSummary($source->getTitle());
-
- //Set description
- $event->setDescription($description);
-
- //Set status
- $event->setStatus(empty($session['a_canceled'])?'confirmed':'cancelled');
-
- //Set location
- $event->setLocation(implode(' ', [$session['l_address'], $session['l_zipcode'], $session['l_city']]));
-
- //Get source
- #$eventSource = $event->getSource();
-
- //Update source title
- #$eventSource->setTitle($source->getTitle());
-
- //Update source url
- #$eventSource->setUrl($source->getUrl());
-
- //Set source
- $event->setSource($source);
-
- //Get extended properties
- #$extendedProperties = $event->getExtendedProperties();
-
- //Update private
- #$extendedProperties->setPrivate($properties->getPrivate());
-
- //Update shared
- #$extendedProperties->setShared($properties->getShared());
-
- //Set properties
- $event->setExtendedProperties($properties);
-
- //TODO: colorId ?
- //TODO: attendees[] ?
-
- //Set start
- $start = $event->getStart();
-
- //Update start datetime
- $start->setDateTime($session['start']->format(\DateTime::ISO8601));
-
- //Set end
- $end = $event->getEnd();
-
- //Update stop datetime
- $end->setDateTime($session['stop']->format(\DateTime::ISO8601));
- }
-
- //Return event
- return $event;
- }
-
- /**
- * Insert event
- *
- * @param string $calendar The calendar mail
- * @param array $session The session instance
- * @return void
- */
- function insert(string $calendar, array $session): void {
- //Get event
- $event = $this->fill($session);
-
- //Get cache events
- $cacheEvents = $this->item->get();
-
- //Insert in cache event
- $cacheEvents[$session['id']] = $this->service->events->insert($calendar, $event);
-
- //Set cache events
- $this->item->set($cacheEvents);
-
- //Save cache item
- $this->cache->saveDeferred($this->item);
- }
-
- /**
- * Update event
- *
- * @param string $calendar The calendar mail
- * @param Event $event The google event instance
- * @param array $session The session instance
- * @return int The session id
- */
- function update(string $calendar, Event $event, array $session): int {
- //Get event
- $event = $this->fill($session, $event);
-
- //Get cache events
- $cacheEvents = $this->item->get();
-
- //Update in cache events
- $cacheEvents[$session['id']] = $this->service->events->update($calendar, $event->getId(), $event);
-
- //Set cache events
- $this->item->set($cacheEvents);
-
- //Save cache item
- $this->cache->saveDeferred($this->item);
-
- //Return session id
- return $session['id'];
- }
-}