X-Git-Url: https://git.rapsys.eu/airbundle/blobdiff_plain/9759014d2a2430ed3e8cd12ebe46e71885d63c14..ed0a31d7a909f78a031e955c87ae55323b6f7ba8:/Command/Calendar2Command.php diff --git a/Command/Calendar2Command.php b/Command/Calendar2Command.php index 76d5c4c..52eafb9 100644 --- a/Command/Calendar2Command.php +++ b/Command/Calendar2Command.php @@ -15,183 +15,599 @@ use Doctrine\Persistence\ManagerRegistry; use Google\Client; use Google\Service\Calendar; -use Google\Service\Oauth2; +use Google\Service\Calendar\Event; +use Google\Service\Calendar\EventExtendedProperties; +use Google\Service\Calendar\EventSource; -use Symfony\Component\Cache\Adapter\FilesystemAdapter; 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\Entity\Session; +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 google client scopes + * Set description + * + * Shown with bin/console list */ - private array $scopes = [ - Calendar::CALENDAR_EVENTS, - Calendar::CALENDAR, - Oauth2::USERINFO_EMAIL - ]; + protected string $description = 'Synchronize sessions in users\' calendar'; /** - * Set google client instance + * Set help + * + * Shown with bin/console --help rapsysair:calendar2 */ - private Client $client; + protected string $help = 'This command synchronize sessions in users\' google calendar'; /** - * Set markdown instance + * Set domain */ - private DefaultMarkdown $markdown; + protected string $domain; /** - * Set date period instance + * Set item + * + * Cache item instance */ - private \DatePeriod $period; + protected ItemInterface $item; /** - * {@inheritdoc} - * - * @param string $project The google project - * @param string $client The google client - * @param string $secret The google secret + * Set prefix */ - public function __construct(ManagerRegistry $doctrine, RouterInterface $router, SluggerUtil $slugger, TranslatorInterface $translator, string $locale, string $project, string $client, string $secret) { - //Call parent constructor - parent::__construct($doctrine, $router, $slugger, $translator, $locale); + protected string $prefix; - //Set google client - $this->client = new Client( - [ - 'application_name' => $project, - 'client_id' => $client, - 'client_secret' => $secret, - 'redirect_uri' => $this->router->generate('rapsys_air_google_callback', [], UrlGeneratorInterface::ABSOLUTE_URL), - 'scopes' => $this->scopes, - 'access_type' => 'offline', - #'login_hint' => $user->getMail(), - //XXX: see https://stackoverflow.com/questions/10827920/not-receiving-google-oauth-refresh-token - #'approval_prompt' => 'force' - 'prompt' => 'consent' - ] - ); - - //Set Markdown instance - $this->markdown = new DefaultMarkdown; - } + /** + * Set service + * + * Google calendar instance + */ + protected Calendar $service; /** - * Configure attribute command + * {@inheritdoc} + * + * @param CacheInterface $cache The cache instance + * @param Client $google The google client instance + * @param DefaultMarkdown $markdown The markdown instance */ - protected function configure() { - //Configure the class - $this - //Set name - ->setName('rapsysair:calendar2') - //Set description shown with bin/console list - ->setDescription('Synchronize sessions in users\' calendar') - //Set description shown with bin/console --help airlibre:attribute - ->setHelp('This command synchronize sessions in users\' google calendar'); + 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 period - $this->period = new \DatePeriod( - //Start from last week - new \DateTime('-1 week'), - //Iterate on each day - new \DateInterval('P1D'), - //End with next 2 week - new \DateTime('+2 week') - ); + //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') ? + ]; - #$calendar['synchronized'] - var_dump($token); + //Set page token + $pageToken = null; - //TODO: see if we may be smarter here ? + //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); - //TODO: load all calendar events here ? + //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($sessions = $this->doctrine->getRepository(Session::class)->findAllByUserIdSynchronized($token['uid'], $calendar['synchronized']) as $session) { - //TODO: insert/update/delete events here ? + 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); + } + } } - //TODO: delete remaining events here ? - } - } + //Get all sessions + $sessions = $this->doctrine->getRepository(Session::class)->findAllByUserIdSynchronized($token['uid']); - //TODO: get user filter ? (users_subscriptions+users_dances) - - //TODO: XXX: or fetch directly the events updated since synchronized + matching rubscriptions and/or dances - - #var_dump($tokens); - exit; - - //Set sql request - $sql =<<doctrine->getRepository(Session::class)->findAllByDanceUserModified($filter['dance'], $filter['user'], $calendar['synchronized']); - //Iterate on google tokens - foreach($tokens as $token) { - //TODO: clear google client cache - //TODO: set google token - //Iterate on google calendars - foreach($calendars as $calendar) { - //Fetch sessions to sync - $sessions = $this->doctrine->getRepository(Session::class)->findAllByDanceUserModified($filter['dance'], $filter['user'], $calendar['synchronized']); + //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('!([^<]+)!', '\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']; + } }