From: Raphaël Gertz Date: Tue, 9 Apr 2024 13:31:37 +0000 (+0200) Subject: New session sync command to google events X-Git-Tag: 0.5.0~4 X-Git-Url: https://git.rapsys.eu/airbundle/commitdiff_plain/85ef482b73d65d8c60d7091b79db19da39dcb594?ds=sidebyside;hp=61e56d402916a10280b733c1644cebd4a28d1f7d New session sync command to google events Split event delete, fill, insert and update in separate functions Consider all events with a domain=example.com private extended property Create event with id constituted of prefix and session id Update calendar synchronized datetime on uncached runs every hour Compute prefix from domain and warn when invalid --- diff --git a/Command/Calendar2Command.php b/Command/Calendar2Command.php index 0ce4da8..52eafb9 100644 --- a/Command/Calendar2Command.php +++ b/Command/Calendar2Command.php @@ -15,13 +15,16 @@ 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; @@ -53,10 +56,38 @@ class Calendar2Command extends Command { */ 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 Client $google, protected DefaultMarkdown $markdown) { + 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); @@ -68,43 +99,515 @@ class Calendar2Command extends Command { * Process the attribution */ protected function execute(InputInterface $input, OutputInterface $output): int { - //Set period - $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') - ); + //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) { - #$calendar['synchronized'] - var_dump($token); + //Set start + $synchronized = null; - //TODO: see if we may be smarter here ? + //Set cache key + $cacheKey = 'command.calendar2.'.$this->slugger->short($calendar['mail']); - //TODO: load all calendar events here ? + //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($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']); + + //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); - //TODO: get user filter ? (users_subscriptions+users_dances) + //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]); + } + } + } - //TODO: XXX: or fetch directly the events updated since synchronized + matching rubscriptions and/or dances + //Persist cache item + $this->cache->commit(); - exit; + //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']; + } }