From 58883dd276b37950932fff7bc166b95404f7b4ca Mon Sep 17 00:00:00 2001 From: =?utf8?q?Rapha=C3=ABl=20Gertz?= Date: Tue, 26 Mar 2024 19:23:49 +0100 Subject: [PATCH 01/16] Inherit captcha --- Resources/config/packages/rapsysair.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Resources/config/packages/rapsysair.yaml b/Resources/config/packages/rapsysair.yaml index 7382568..02197a8 100644 --- a/Resources/config/packages/rapsysair.yaml +++ b/Resources/config/packages/rapsysair.yaml @@ -349,7 +349,7 @@ services: tags: [ 'form.type' ] # Register register form Rapsys\AirBundle\Form\RegisterType: - arguments: [ '@doctrine.orm.entity_manager' ] + arguments: [ '@doctrine.orm.entity_manager', '@rapsyspack.image_util', '@rapsyspack.slugger_util', '@translator' ] tags: [ 'form.type' ] # Register attribute command Rapsys\AirBundle\Command\AttributeCommand: -- 2.41.0 From 8b42e55e661fe05af15b0cf13d3e2bdfc094fa1a Mon Sep 17 00:00:00 2001 From: =?utf8?q?Rapha=C3=ABl=20Gertz?= Date: Tue, 26 Mar 2024 19:24:15 +0100 Subject: [PATCH 02/16] Use new form name --- Resources/views/form/recover.html.twig | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Resources/views/form/recover.html.twig b/Resources/views/form/recover.html.twig index dc080dd..7307248 100644 --- a/Resources/views/form/recover.html.twig +++ b/Resources/views/form/recover.html.twig @@ -7,22 +7,22 @@ {% if sent %}

{% trans %}Your recover account message has been sent{% endtrans %}

{% else %} - {{ form_start(form) }} + {{ form_start(recover) }}
- {% if form.mail is defined %} - {{ form_row(form.mail) }} + {% if recover.mail is defined %} + {{ form_row(recover.mail) }} {% endif %} - {% if form.password is defined %} - {{ form_row(form.password) }} + {% if recover.password is defined %} + {{ form_row(recover.password) }} {% endif %} - {{ form_row(form.submit) }} + {{ form_row(recover.submit) }}
{# Render CSRF token etc .#} -
{{ form_rest(form) }}
- {{ form_end(form) }} +
{{ form_rest(recover) }}
+ {{ form_end(recover) }} {% endif %} {% endblock %} -- 2.41.0 From 94dc9530e044a22082712263ac89d7ac677ed0d0 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Rapha=C3=ABl=20Gertz?= Date: Tue, 26 Mar 2024 19:24:39 +0100 Subject: [PATCH 03/16] Add captcha row --- Resources/views/form/register.html.twig | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Resources/views/form/register.html.twig b/Resources/views/form/register.html.twig index 74e3aa2..cd71a78 100644 --- a/Resources/views/form/register.html.twig +++ b/Resources/views/form/register.html.twig @@ -43,6 +43,10 @@ {{ form_row(register.phone) }} {% endif %} + {% if register.captcha is defined %} + {{ form_row(register.captcha) }} + {% endif %} + {{ form_row(register.submit) }} {# Render CSRF token etc .#} -- 2.41.0 From 0f09ede543b60204768e54f05d4fff0f39e7e57c Mon Sep 17 00:00:00 2001 From: =?utf8?q?Rapha=C3=ABl=20Gertz?= Date: Tue, 2 Apr 2024 05:45:01 +0200 Subject: [PATCH 04/16] Enable contact captcha --- Controller/DefaultController.php | 1 + Form/ContactType.php | 3 +++ 2 files changed, 4 insertions(+) diff --git a/Controller/DefaultController.php b/Controller/DefaultController.php index 647baf8..1459096 100644 --- a/Controller/DefaultController.php +++ b/Controller/DefaultController.php @@ -105,6 +105,7 @@ class DefaultController extends AbstractController { //And give the proper parameters $form = $this->factory->create('Rapsys\AirBundle\Form\ContactType', $data, [ 'action' => $this->generateUrl('rapsysair_contact'), + 'captcha' => true, 'method' => 'POST' ]); diff --git a/Form/ContactType.php b/Form/ContactType.php index 93951e9..4142c99 100644 --- a/Form/ContactType.php +++ b/Form/ContactType.php @@ -46,6 +46,9 @@ class ContactType extends CaptchaType { * {@inheritdoc} */ public function configureOptions(OptionsResolver $resolver): void { + //Call parent configure options + parent::configureOptions($resolver); + //Set defaults $resolver->setDefaults(['error_bubbling' => true]); } -- 2.41.0 From dbe6f6710034af730e633e1c810ad7f020fe9551 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Rapha=C3=ABl=20Gertz?= Date: Tue, 2 Apr 2024 05:45:46 +0200 Subject: [PATCH 05/16] Version 0.5.0 --- RapsysAirBundle.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RapsysAirBundle.php b/RapsysAirBundle.php index 6096959..e1bb77b 100644 --- a/RapsysAirBundle.php +++ b/RapsysAirBundle.php @@ -62,6 +62,6 @@ class RapsysAirBundle extends Bundle { */ public static function getVersion(): string { //Return version - return '0.4.0'; + return '0.5.0'; } } -- 2.41.0 From 5bdc01337b213b789f58b3a02596688082cfc129 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Rapha=C3=ABl=20Gertz?= Date: Sun, 7 Apr 2024 03:54:15 +0200 Subject: [PATCH 06/16] Fix subscribed key name --- Resources/config/doctrine/User.orm.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Resources/config/doctrine/User.orm.yml b/Resources/config/doctrine/User.orm.yml index 6002651..fcc5712 100644 --- a/Resources/config/doctrine/User.orm.yml +++ b/Resources/config/doctrine/User.orm.yml @@ -54,7 +54,7 @@ Rapsys\AirBundle\Entity\User: name: user_id inverseJoinColumns: id: - name: subscriber_id + name: subscribed_id locations: targetEntity: Rapsys\AirBundle\Entity\Location inversedBy: users -- 2.41.0 From 85e3d49aa800d5e289f376023173847e0a2077f0 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Rapha=C3=ABl=20Gertz?= Date: Sun, 7 Apr 2024 03:55:22 +0200 Subject: [PATCH 07/16] Fix subscribed key name --- Repository/SessionRepository.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Repository/SessionRepository.php b/Repository/SessionRepository.php index d44db55..e264552 100644 --- a/Repository/SessionRepository.php +++ b/Repository/SessionRepository.php @@ -976,9 +976,9 @@ SQL; //Set the request $req = <<addScalarResult('user_id', 'user_id', 'integer'); + $rsm->addScalarResult('subscribed_id', 'subscribed_id', 'integer'); //Set subscription sql part $subscriptionSql = ''; -- 2.41.0 From 36367b3d9770a29f2e516e40524c5d4eef8a9aca Mon Sep 17 00:00:00 2001 From: =?utf8?q?Rapha=C3=ABl=20Gertz?= Date: Sun, 7 Apr 2024 04:00:49 +0200 Subject: [PATCH 08/16] Append dances and subscriptions to findAllIndexed results --- Repository/GoogleTokenRepository.php | 87 +++++++++++++++++++++++----- 1 file changed, 71 insertions(+), 16 deletions(-) diff --git a/Repository/GoogleTokenRepository.php b/Repository/GoogleTokenRepository.php index fa2a854..7c96e72 100644 --- a/Repository/GoogleTokenRepository.php +++ b/Repository/GoogleTokenRepository.php @@ -29,20 +29,51 @@ class GoogleTokenRepository extends Repository { //Set the request $req = <<addScalarResult('cmails', 'cmails', 'string') ->addScalarResult('csummaries', 'csummaries', 'string') ->addScalarResult('csynchronizeds', 'csynchronizeds', 'string') + ->addScalarResult('dids', 'dids', 'string') + ->addScalarResult('sids', 'sids', 'string') ->addIndexByScalar('tid'); //Set result array @@ -98,10 +131,12 @@ SQL; 'access' => $token['access'], 'refresh' => $token['refresh'], 'expired' => $token['expired'], - 'calendars' => [] + 'calendars' => [], + 'dances' => [], + 'subscriptions' => [] ]; - //Iterate on + //Iterate on calendars foreach($cids as $k => $cid) { $result[$tid]['calendars'][$cid] = [ 'id' => $cid, @@ -110,6 +145,26 @@ SQL; 'synchronized' => $csynchronizeds[$k] ]; } + + //Set dids + $dids = explode("\n", $token['dids']); + + //Iterate on dances + foreach($dids as $k => $did) { + $result[$tid]['dances'][$did] = [ + 'id' => $did + ]; + } + + //Set sids + $sids = explode("\n", $token['sids']); + + //Iterate on subscriptions + foreach($sids as $k => $sid) { + $result[$tid]['subscriptions'][$sid] = [ + 'id' => $sid + ]; + } } //Return result -- 2.41.0 From e5797b46f0390e3d269122b1197fdfe7f0abbe70 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Rapha=C3=ABl=20Gertz?= Date: Tue, 9 Apr 2024 06:10:52 +0200 Subject: [PATCH 09/16] Add missing translation --- Resources/translations/messages.en_gb.yaml | 1 + Resources/translations/messages.fr_fr.yaml | 1 + 2 files changed, 2 insertions(+) diff --git a/Resources/translations/messages.en_gb.yaml b/Resources/translations/messages.en_gb.yaml index 5bb0f5d..e6c2f81 100644 --- a/Resources/translations/messages.en_gb.yaml +++ b/Resources/translations/messages.en_gb.yaml @@ -164,6 +164,7 @@ 'Class': 'Class' 'Colette': 'Colette' 'Colette place': 'Colette place' +'Colette place access map': 'Colette place access map' 'Colette place miniature': 'Colette place miniature' 'Colette place sector map': 'Colette place sector map' 'Completely offline (neither deezer nor spotify)': 'Completely offline (neither deezer nor spotify)' diff --git a/Resources/translations/messages.fr_fr.yaml b/Resources/translations/messages.fr_fr.yaml index 628909a..a82368f 100644 --- a/Resources/translations/messages.fr_fr.yaml +++ b/Resources/translations/messages.fr_fr.yaml @@ -168,6 +168,7 @@ 'Civility': 'Civilité' 'Class': 'Cours' 'Colette': 'Colette' +'Colette place access map': 'Carte d''accès de la place Colette' 'Colette place miniature': 'Miniature de la place Colette' 'Colette place': 'Place Colette' 'Colette place sector map': 'Carte du secteur de la place Colette' -- 2.41.0 From eb1f263248166f36f5eb7dc17e39721e80b3e7c2 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Rapha=C3=ABl=20Gertz?= Date: Tue, 9 Apr 2024 06:11:36 +0200 Subject: [PATCH 10/16] Add created field to findAllIndexed results --- Repository/GoogleTokenRepository.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Repository/GoogleTokenRepository.php b/Repository/GoogleTokenRepository.php index 7c96e72..29b96d2 100644 --- a/Repository/GoogleTokenRepository.php +++ b/Repository/GoogleTokenRepository.php @@ -34,6 +34,7 @@ SELECT b.uid, b.access, b.refresh, + b.created, b.expired, b.cids, b.cmails, @@ -48,6 +49,7 @@ FROM ( a.uid, a.access, a.refresh, + a.created, a.expired, a.cids, a.cmails, @@ -61,6 +63,7 @@ FROM ( t.user_id AS uid, t.access, t.refresh, + t.created, t.expired, GROUP_CONCAT(c.id ORDER BY c.id SEPARATOR "\\n") AS cids, GROUP_CONCAT(c.mail ORDER BY c.id SEPARATOR "\\n") AS cmails, @@ -92,6 +95,7 @@ SQL; ->addScalarResult('uid', 'uid', 'integer') ->addScalarResult('access', 'access', 'string') ->addScalarResult('refresh', 'refresh', 'string') + ->addScalarResult('created', 'created', 'datetime') ->addScalarResult('expired', 'expired', 'datetime') ->addScalarResult('cids', 'cids', 'string') ->addScalarResult('cmails', 'cmails', 'string') @@ -130,6 +134,7 @@ SQL; 'uid' => $token['uid'], 'access' => $token['access'], 'refresh' => $token['refresh'], + 'created' => $token['created'], 'expired' => $token['expired'], 'calendars' => [], 'dances' => [], -- 2.41.0 From 61e56d402916a10280b733c1644cebd4a28d1f7d Mon Sep 17 00:00:00 2001 From: =?utf8?q?Rapha=C3=ABl=20Gertz?= Date: Tue, 9 Apr 2024 06:12:29 +0200 Subject: [PATCH 11/16] Add user cache to constructor arguments --- Resources/config/packages/rapsysair.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Resources/config/packages/rapsysair.yaml b/Resources/config/packages/rapsysair.yaml index 02197a8..9def36c 100644 --- a/Resources/config/packages/rapsysair.yaml +++ b/Resources/config/packages/rapsysair.yaml @@ -361,7 +361,7 @@ services: tags: [ 'console.command' ] # Register calendar2 command Rapsys\AirBundle\Command\Calendar2Command: - arguments: [ '@doctrine', '%kernel.default_locale%', '@router', '@rapsyspack.slugger_util', '@translator', '@google.client', '@twig.markdown.default' ] + arguments: [ '@doctrine', '%kernel.default_locale%', '@router', '@rapsyspack.slugger_util', '@translator', '@user.cache', '@google.client', '@twig.markdown.default' ] tags: [ 'console.command' ] # Register rekey command Rapsys\AirBundle\Command\RekeyCommand: -- 2.41.0 From 85ef482b73d65d8c60d7091b79db19da39dcb594 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Rapha=C3=ABl=20Gertz?= Date: Tue, 9 Apr 2024 15:31:37 +0200 Subject: [PATCH 12/16] 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 --- Command/Calendar2Command.php | 551 +++++++++++++++++++++++++++++++++-- 1 file changed, 527 insertions(+), 24 deletions(-) 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']; + } } -- 2.41.0 From a260fe7b849a7d64ee977012b28ddbb738727010 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Rapha=C3=ABl=20Gertz?= Date: Tue, 9 Apr 2024 15:32:27 +0200 Subject: [PATCH 13/16] Fix coalesce warning Improve complement locations search request --- Repository/LocationRepository.php | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/Repository/LocationRepository.php b/Repository/LocationRepository.php index 963f2c8..9b621ef 100644 --- a/Repository/LocationRepository.php +++ b/Repository/LocationRepository.php @@ -134,7 +134,7 @@ FROM ( l.latitude, l.longitude, l.indoor, - GREATEST(l.created, l.updated, COALESCE(s.created, 0), COALESCE(s.updated, 0)) AS modified, + GREATEST(l.created, l.updated, COALESCE(s.created, '1970-01-01'), COALESCE(s.updated, '1970-01-01')) AS modified, l.zipcode, COUNT(s.id) AS count, COUNT(IF(s.date BETWEEN :begin AND :end, s.id, NULL)) AS pcount @@ -362,7 +362,7 @@ SELECT a.longitude, a.created, a.updated, - MAX(GREATEST(a.modified, COALESCE(s.created, 0), COALESCE(s.updated, 0))) AS modified, + MAX(GREATEST(a.modified, COALESCE(s.created, '1970-01-01'), COALESCE(s.updated, '1970-01-01'))) AS modified, COUNT(s.id) AS count FROM ( SELECT @@ -461,7 +461,7 @@ SELECT a.longitude, a.created, a.updated, - MAX(GREATEST(a.modified, COALESCE(s3.created, 0), COALESCE(s3.updated, 0))) AS modified, + MAX(GREATEST(a.modified, COALESCE(s3.created, '1970-01-01'), COALESCE(s3.updated, '1970-01-01'))) AS modified, a.pcount, COUNT(s3.id) AS tcount FROM ( @@ -473,7 +473,7 @@ FROM ( b.longitude, b.created, b.updated, - MAX(GREATEST(b.modified, COALESCE(s2.created, 0), COALESCE(s2.updated, 0))) AS modified, + MAX(GREATEST(b.modified, COALESCE(s2.created, '1970-01-01'), COALESCE(s2.updated, '1970-01-01'))) AS modified, COUNT(s2.id) AS pcount FROM ( SELECT @@ -716,18 +716,15 @@ SQL; */ public function findComplementBySessionId(int $id): array { //Fetch complement locations - $ret = $this->getEntityManager() - ->createQuery('SELECT l.id, l.title FROM Rapsys\AirBundle\Entity\Session s LEFT JOIN Rapsys\AirBundle\Entity\Session s2 WITH s2.id != s.id AND s2.slot = s.slot AND s2.date = s.date LEFT JOIN Rapsys\AirBundle\Entity\Location l WITH l.id != s.location AND (l.id != s2.location OR s2.location IS NULL) WHERE s.id = :sid GROUP BY l.id ORDER BY l.id') - ->setParameter('sid', $id) - ->getArrayResult(); - - //TODO: try to improve with: - #->addIndexByScalar('city'); - - //Rekey array - $ret = array_column($ret, 'id', 'title'); - - return $ret; + return array_column( + $this->getEntityManager() + #->createQuery('SELECT l.id, l.title FROM Rapsys\AirBundle\Entity\Location l JOIN Rapsys\AirBundle\Entity\Session s WITH s.id = :sid LEFT JOIN Rapsys\AirBundle\Entity\Session s2 WITH s2.id != s.id AND s2.slot = s.slot AND s2.date = s.date WHERE l.id != s.location AND s2.location IS NULL GROUP BY l.id ORDER BY l.id') + ->createQuery('SELECT l.id, l.title FROM Rapsys\AirBundle\Entity\Session s LEFT JOIN Rapsys\AirBundle\Entity\Session s2 WITH s2.id != s.id AND s2.slot = s.slot AND s2.date = s.date LEFT JOIN Rapsys\AirBundle\Entity\Location l WITH l.id != s.location AND (l.id != s2.location OR s2.location IS NULL) WHERE s.id = :sid GROUP BY l.id ORDER BY l.id') + ->setParameter('sid', $id) + ->getArrayResult(), + 'id', + 'title' + ); } /** -- 2.41.0 From 9413ff9b480fca9b0be354069e478758a1cee794 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Rapha=C3=ABl=20Gertz?= Date: Tue, 9 Apr 2024 15:34:56 +0200 Subject: [PATCH 14/16] Fix coalesce warning Improve findAllByUserIdSynchronized modified field and synchronized comparing --- Repository/SessionRepository.php | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/Repository/SessionRepository.php b/Repository/SessionRepository.php index e264552..c310b54 100644 --- a/Repository/SessionRepository.php +++ b/Repository/SessionRepository.php @@ -106,7 +106,7 @@ SELECT p.profile AS p_profile, p.rate AS p_rate, p.hat AS p_hat, - GREATEST(COALESCE(s.updated, 0), COALESCE(l.updated, 0), COALESCE(t.updated, 0), COALESCE(p.updated, 0), COALESCE(MAX(sa.updated), 0), COALESCE(MAX(sau.updated), 0), COALESCE(MAX(sad.updated), 0)) AS modified, + GREATEST(s.created, s.updated, l.created, l.updated, t.created, t.updated, COALESCE(a.created, '1970-01-01'), COALESCE(a.updated, '1970-01-01'), COALESCE(ad.created, '1970-01-01'), COALESCE(ad.updated, '1970-01-01'), COALESCE(au.created, '1970-01-01'), COALESCE(au.updated, '1970-01-01'), COALESCE(p.created, '1970-01-01'), COALESCE(p.updated, '1970-01-01'), MAX(GREATEST(COALESCE(sa.created, '1970-01-01'), COALESCE(sa.updated, '1970-01-01'), COALESCE(sad.created, '1970-01-01'), COALESCE(sad.updated, '1970-01-01'), COALESCE(sau.created, '1970-01-01'), COALESCE(sau.updated, '1970-01-01')))) AS modified, GROUP_CONCAT(sa.id ORDER BY sa.user_id SEPARATOR "\\n") AS sa_id, GROUP_CONCAT(IFNULL(sa.score, 'NULL') ORDER BY sa.user_id SEPARATOR "\\n") AS sa_score, GROUP_CONCAT(sa.created ORDER BY sa.user_id SEPARATOR "\\n") AS sa_created, @@ -122,8 +122,8 @@ LEFT JOIN Rapsys\AirBundle\Entity\Dance AS ad ON (ad.id = a.dance_id) LEFT JOIN Rapsys\AirBundle\Entity\User AS au ON (au.id = a.user_id) LEFT JOIN Rapsys\AirBundle\Entity\Snippet AS p ON (p.locale = :locale AND p.location_id = s.location_id AND p.user_id = a.user_id) LEFT JOIN Rapsys\AirBundle\Entity\Application AS sa ON (sa.session_id = s.id) -LEFT JOIN Rapsys\AirBundle\Entity\User AS sau ON (sau.id = sa.user_id) LEFT JOIN Rapsys\AirBundle\Entity\Dance AS sad ON (sad.id = sa.dance_id) +LEFT JOIN Rapsys\AirBundle\Entity\User AS sau ON (sau.id = sa.user_id) WHERE s.id = :id GROUP BY s.id ORDER BY NULL @@ -553,12 +553,12 @@ SELECT p.hat AS p_hat, p.rate AS p_rate, p.short AS p_short, - GROUP_CONCAT(sa.user_id ORDER BY sa.user_id SEPARATOR "\\n") AS sau_id, - GROUP_CONCAT(sau.pseudonym ORDER BY sa.user_id SEPARATOR "\\n") AS sau_pseudonym, + GREATEST(s.created, s.updated, l.created, l.updated, t.created, t.updated, COALESCE(a.created, '1970-01-01'), COALESCE(a.updated, '1970-01-01'), COALESCE(ad.created, '1970-01-01'), COALESCE(ad.updated, '1970-01-01'), COALESCE(au.created, '1970-01-01'), COALESCE(au.updated, '1970-01-01'), COALESCE(p.created, '1970-01-01'), COALESCE(p.updated, '1970-01-01'), MAX(GREATEST(COALESCE(sa.created, '1970-01-01'), COALESCE(sa.updated, '1970-01-01'), COALESCE(sad.created, '1970-01-01'), COALESCE(sad.updated, '1970-01-01'), COALESCE(sau.created, '1970-01-01'), COALESCE(sau.updated, '1970-01-01')))) AS modified, GROUP_CONCAT(sa.dance_id ORDER BY sa.user_id SEPARATOR "\\n") AS sad_id, GROUP_CONCAT(sad.name ORDER BY sa.user_id SEPARATOR "\\n") AS sad_name, GROUP_CONCAT(sad.type ORDER BY sa.user_id SEPARATOR "\\n") AS sad_type, - GREATEST(COALESCE(s.updated, 0), COALESCE(l.updated, 0), COALESCE(p.updated, 0), COALESCE(MAX(sa.updated), 0), COALESCE(MAX(sau.updated), 0), COALESCE(MAX(sad.updated), 0)) AS modified + GROUP_CONCAT(sa.user_id ORDER BY sa.user_id SEPARATOR "\\n") AS sau_id, + GROUP_CONCAT(sau.pseudonym ORDER BY sa.user_id SEPARATOR "\\n") AS sau_pseudonym FROM Rapsys\AirBundle\Entity\Session AS s JOIN Rapsys\AirBundle\Entity\Location AS l ON (l.id = s.location_id) JOIN Rapsys\AirBundle\Entity\Slot AS t ON (t.id = s.slot_id) @@ -615,15 +615,15 @@ SQL; ->addScalarResult('p_rate', 'p_rate', 'integer') ->addScalarResult('p_short', 'p_short', 'string') //XXX: is a string because of \n separator - ->addScalarResult('sau_id', 'sau_id', 'string') - //XXX: is a string because of \n separator - ->addScalarResult('sau_pseudonym', 'sau_pseudonym', 'string') - //XXX: is a string because of \n separator ->addScalarResult('sad_id', 'sad_id', 'string') //XXX: is a string because of \n separator ->addScalarResult('sad_name', 'sad_name', 'string') //XXX: is a string because of \n separator ->addScalarResult('sad_type', 'sad_type', 'string') + //XXX: is a string because of \n separator + ->addScalarResult('sau_id', 'sau_id', 'string') + //XXX: is a string because of \n separator + ->addScalarResult('sau_pseudonym', 'sau_pseudonym', 'string') ->addIndexByScalar('id'); //Fetch result @@ -943,7 +943,7 @@ SQL; * @param DateTime $synchronized The synchronized datetime * @return array The session data */ - public function findAllByUserIdSynchronized(int $userId, \DateTime $synchronized): array { + public function findAllByUserIdSynchronized(int $userId, \DateTime $synchronized = new \DateTime('1970-01-01')): array { //Set the request $req = <<= :synchronized +WHERE GREATEST(GREATEST(s.created, s.updated, l.created, l.updated, a.created, a.updated, ad.created, ad.updated, au.created, au.updated, COALESCE(p.created, '1970-01-01'), COALESCE(p.updated, '1970-01-01')), ADDDATE(ADDTIME(ADDTIME(s.date, s.begin), s.length), INTERVAL IF(s.slot_id = :afterid, 1, 0) DAY)) >= :synchronized SQL; //Replace bundle entity name by table name @@ -1060,7 +1060,7 @@ SQL; ->addScalarResult('id', 'id', 'integer') ->addScalarResult('date', 'date', 'date') ->addScalarResult('locked', 'locked', 'datetime') - ->addScalarResult('updated', 'updated', 'datetime') + ->addScalarResult('modified', 'modified', 'datetime') ->addScalarResult('start', 'start', 'datetime') ->addScalarResult('stop', 'stop', 'datetime') ->addScalarResult('l_id', 'l_id', 'integer') @@ -1099,7 +1099,7 @@ SQL; ->setParameter('dids', $userDances) ->setParameter('uids', $userSubscriptions) ->setParameter('synchronized', $synchronized) - ->getArrayResult(); + ->getResult(AbstractQuery::HYDRATE_ARRAY); } /** -- 2.41.0 From ed0a31d7a909f78a031e955c87ae55323b6f7ba8 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Rapha=C3=ABl=20Gertz?= Date: Tue, 9 Apr 2024 15:39:44 +0200 Subject: [PATCH 15/16] Fix coalesce warning Cleanup --- Repository/UserRepository.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Repository/UserRepository.php b/Repository/UserRepository.php index 4e7dae8..725e3ed 100644 --- a/Repository/UserRepository.php +++ b/Repository/UserRepository.php @@ -218,9 +218,9 @@ SELECT c.title AS c_title, u.country_id AS o_id, o.title AS o_title, + GREATEST(u.created, u.updated, COALESCE(c.created, '1970-01-01'), COALESCE(c.updated, '1970-01-01'), COALESCE(o.created, '1970-01-01'), COALESCE(o.updated, '1970-01-01'), COALESCE(g.created, '1970-01-01'), COALESCE(g.updated, '1970-01-01')) AS modified, GROUP_CONCAT(g.id ORDER BY g.id SEPARATOR "\\n") AS ids, - GROUP_CONCAT(g.title ORDER BY g.id SEPARATOR "\\n") AS titles, - GREATEST(COALESCE(u.updated, 0), COALESCE(c.updated, 0), COALESCE(o.updated, 0)) AS modified + GROUP_CONCAT(g.title ORDER BY g.id SEPARATOR "\\n") AS titles FROM Rapsys\AirBundle\Entity\User AS u LEFT JOIN Rapsys\AirBundle\Entity\Civility AS c ON (c.id = u.civility_id) LEFT JOIN Rapsys\AirBundle\Entity\Country AS o ON (o.id = u.country_id) @@ -252,11 +252,11 @@ SQL; ->addScalarResult('c_title', 'c_title', 'string') ->addScalarResult('o_id', 'o_id', 'integer') ->addScalarResult('o_title', 'o_title', 'string') + ->addScalarResult('modified', 'modified', 'datetime') //XXX: is a string because of \n separator ->addScalarResult('ids', 'ids', 'string') //XXX: is a string because of \n separator ->addScalarResult('titles', 'titles', 'string') - ->addScalarResult('modified', 'modified', 'datetime') ->addIndexByScalar('id'); //Get result -- 2.41.0 From 5ec45cd7fa8b353f4f211c0abfcc0c875a938c7e Mon Sep 17 00:00:00 2001 From: =?utf8?q?Rapha=C3=ABl=20Gertz?= Date: Tue, 9 Apr 2024 15:44:41 +0200 Subject: [PATCH 16/16] Rename rapsysair:calendar2 command to rapsysair:calendar --- Command/Calendar2Command.php | 613 ---------------- Command/CalendarCommand.php | 893 +++++++++++++---------- Resources/config/packages/rapsysair.yaml | 4 - 3 files changed, 520 insertions(+), 990 deletions(-) delete mode 100644 Command/Calendar2Command.php diff --git a/Command/Calendar2Command.php b/Command/Calendar2Command.php deleted file mode 100644 index 52eafb9..0000000 --- a/Command/Calendar2Command.php +++ /dev/null @@ -1,613 +0,0 @@ - - * - * 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('!([^<]+)!', '\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']; - } -} diff --git a/Command/CalendarCommand.php b/Command/CalendarCommand.php index dca096b..f722aae 100644 --- a/Command/CalendarCommand.php +++ b/Command/CalendarCommand.php @@ -12,455 +12,602 @@ namespace Rapsys\AirBundle\Command; use Doctrine\Persistence\ManagerRegistry; -use Symfony\Component\Cache\Adapter\FilesystemAdapter; + +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\DependencyInjection\ContainerInterface; 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' google calendar + */ class CalendarCommand extends Command { /** - * Creates new calendar command + * Set description * - * @param ManagerRegistry $doctrine The doctrine instance - * @param string $locale The default locale - * @param RouterInterface $router The router instance - * @param SluggerUtil $slugger The slugger instance - * @param TranslatorInterface $translator The translator instance - * @param string $namespace The cache namespace - * @param int $lifetime The cache lifetime - * @param string $path The cache path + * Shown with bin/console list */ - public function __construct(protected ManagerRegistry $doctrine, protected string $locale, protected RouterInterface $router, protected SluggerUtil $slugger, protected TranslatorInterface $translator, protected string $namespace, protected int $lifetime, protected string $path) { - //Call parent constructor - parent::__construct($this->doctrine, $this->locale, $this->router, $this->slugger, $this->translator); - } + protected string $description = 'Synchronize sessions in users\' calendar'; /** - * Configure attribute command + * Set help + * + * Shown with bin/console --help rapsysair:calendar */ - protected function configure() { - //Configure the class - $this - //Set name - ->setName('rapsysair:calendar') - //Set description shown with bin/console list - ->setDescription('Synchronize sessions in calendar') - //Set description shown with bin/console --help airlibre:attribute - ->setHelp('This command synchronize sessions in google calendar'); - } + protected string $help = 'This command synchronize sessions in users\' google calendar'; /** - * Process the attribution + * Set domain */ - protected function execute(InputInterface $input, OutputInterface $output): int { - //Compute 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') - ); + protected string $domain; - //Retrieve events to update - $sessions = $this->doctrine->getRepository(Session::class)->fetchAllByDatePeriod($period, $this->locale); + /** + * Set item + * + * Cache item instance + */ + protected ItemInterface $item; - //Markdown converted instance - $markdown = new DefaultMarkdown; + /** + * Set prefix + */ + protected string $prefix; - //Retrieve cache object - //XXX: by default stored in /tmp/symfony-cache/@/W/3/6SEhFfeIW4UMDlAII+Dg - //XXX: stored in %kernel.project_dir%/var/cache/airlibre/0/P/IA20X0K4dkMd9-+Ohp9Q - $cache = new FilesystemAdapter($this->namespace, $this->lifetime, $this->path); + /** + * Set service + * + * Google calendar instance + */ + protected Calendar $service; - //Retrieve calendars - $cacheCalendars = $cache->getItem('calendars'); + /** + * {@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); - //Without calendars - if (!$cacheCalendars->isHit()) { - //Return failure - return self::FAILURE; - } + //Replace google client redirect uri + $this->google->setRedirectUri($this->router->generate($this->google->getRedirectUri(), [], UrlGeneratorInterface::ABSOLUTE_URL)); + } - //Retrieve calendars - $calendars = $cacheCalendars->get(); - - //XXX: calendars content - #var_export($calendars); - - //Check expired token - foreach($calendars as $clientId => $client) { - //Get google client - $googleClient = new \Google\Client(['application_name' => $client['project'], 'client_id' => $clientId, 'client_secret' => $client['secret'], 'redirect_uri' => $client['redirect']]); - - //Iterate on each tokens - foreach($client['tokens'] as $tokenId => $token) { - //Set token - $googleClient->setAccessToken( - [ - 'access_token' => $tokenId, - 'refresh_token' => $token['refresh'], - 'expires_in' => $token['expire'], - 'scope' => $token['scope'], - 'token_type' => $token['type'], - 'created' => $token['created'] - ] - ); - - //With expired token - if ($exp = $googleClient->isAccessTokenExpired()) { - //Refresh token - if (($refreshToken = $googleClient->getRefreshToken()) && ($googleToken = $googleClient->fetchAccessTokenWithRefreshToken($refreshToken)) && empty($googleToken['error'])) { - //Add refreshed token - $calendars[$clientId]['tokens'][$googleToken['access_token']] = [ - 'calendar' => $token['calendar'], - 'prefix' => $token['prefix'], - 'refresh' => $googleToken['refresh_token'], - 'expire' => $googleToken['expires_in'], - 'scope' => $googleToken['scope'], - 'type' => $googleToken['token_type'], - 'created' => $googleToken['created'] - ]; - - //Remove old token - unset($calendars[$clientId]['tokens'][$tokenId]); - } else { - //Drop token - unset($calendars[$clientId]['tokens'][$tokenId]); - - //Without tokens - if (empty($calendars[$clientId]['tokens'])) { - //Drop client - unset($calendars[$clientId]); - } + /** + * Process the attribution + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + //Get domain + $this->domain = $this->router->getContext()->getHost(); - //Save calendars - $cacheCalendars->set($calendars); + //Get manager + $manager = $this->doctrine->getManager(); - //Save calendar - $cache->save($cacheCalendars); + //Convert from any to latin, then to ascii and lowercase + $trans = \Transliterator::create('Any-Latin; Latin-ASCII; Lower()'); - //Drop token and report - //XXX: submit app to avoid expiration - //XXX: see https://console.cloud.google.com/apis/credentials/consent?project=calendar-317315 - echo 'Token '.$tokenId.' for calendar '.$token['calendar'].' has expired and is not refreshable'."\n"; + //Replace every non alphanumeric character by dash then trim dash + $this->prefix = preg_replace(['/(\.[a-z0-9]+)+$/', '/[^a-v0-9]+/'], '', $trans->transliterate($this->domain)); - //Return failure - //XXX: we want that mail and stop here - return self::FAILURE; - } - } - } + //With too short prefix + if ($this->prefix === null || strlen($this->prefix) < 4) { + //Throw domain exception + throw new \DomainException('Prefix too short: '.$this->prefix); } - //Save calendars - $cacheCalendars->set($calendars); - - //Save calendar - $cache->save($cacheCalendars); - - //Iterate on each calendar client - foreach($calendars as $clientId => $client) { - //Get google client - $googleClient = new \Google\Client(['application_name' => $client['project'], 'client_id' => $clientId, 'client_secret' => $client['secret'], 'redirect_uri' => $client['redirect']]); - - //Iterate on each tokens - foreach($client['tokens'] as $tokenId => $token) { - //Set token - $googleClient->setAccessToken( - [ - 'access_token' => $tokenId, - 'refresh_token' => $token['refresh'], - 'expires_in' => $token['expire'], - 'scope' => $token['scope'], - 'token_type' => $token['type'], - 'created' => $token['created'] - ] - ); - - //With expired token - if ($exp = $googleClient->isAccessTokenExpired()) { - //Last chance to skip this run + //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 - $googleCalendar = new \Google\Service\Calendar($googleClient); + //Get google calendar service + $this->service = new Calendar($this->google); - //Retrieve calendar + //Iterate on google calendars + foreach($calendars = $token['calendars'] as $cid => $calendar) { + //Set start + $synchronized = null; + + //Set cache key + $cacheKey = 'command.calendar.'.$this->slugger->short($calendar['mail']); + + //XXX: TODO: remove DEBUG + #$this->cache->delete($cacheKey); + + //Retrieve calendar events try { - $calendar = $googleCalendar->calendars->get($token['calendar']); + //Get events + $events = $this->cache->get( + //Cache key + //XXX: set to command.calendar.$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) { - //Display exception - //TODO: handle codes here https://developers.google.com/calendar/api/guides/errors - echo 'Exception '.$e->getCode().':'.$e->getMessage().' in '.$e->getFile().' +'.$e->getLine()."\n"; - echo $e->getTraceAsString()."\n"; + //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()?:''); - //Return failure - return self::FAILURE; + //TODO: warn user by mail ? + + //Skip to next token + continue; + } + + //Throw error + throw new \LogicException('Calendar event list failed', 0, $e); } - //Init events - $events = []; - - //Set filters - $filters = [ - //XXX: show even deleted event to be able to update them - 'showDeleted' => true, - //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', */ - ]; - - //Retrieve event collection - $googleEvents = $googleCalendar->events->listEvents($token['calendar'], $filters); - - //Iterate until reached end - while (true) { - //Iterate on each event - foreach ($googleEvents->getItems() as $event) { - //Store event by id - if (preg_match('/^'.$token['prefix'].'([0-9]+)$/', $id = $event->getId(), $matches)) { - $events[$matches[1]] = $event; - //XXX: 3rd party events with id not matching prefix are skipped - #} else { - # echo 'Skipping '.$event->getId().':'.$event->getSummary()."\n";*/ + //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 page token - $pageToken = $googleEvents->getNextPageToken(); + //Get all sessions + $sessions = $this->doctrine->getRepository(Session::class)->findAllByUserIdSynchronized($token['uid']); - //Handle next page - if ($pageToken) { - //Replace collection with next one - $googleEvents = $service->events->listEvents($token['calendar'], $filters+['pageToken' => $pageToken]); - } else { - break; + //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]); + } } } - //Iterate on each session to sync - foreach($sessions as $sessionId => $session) { - //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 = [ - 'title' => $this->translator->trans('%dance% %id% by %pseudonym%', ['%id%' => $sessionId, '%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' => $sessionId, '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'] = $markdown->convert(strip_tags($session['l_description'])); - - //Add description - $description .= "\n\n".'Description :'."\n".strip_tags(preg_replace('!([^<]+)!', '\1', $markdown->convert(strip_tags($session['p_description'])))); - $shared['description'] = $markdown->convert(strip_tags($session['p_description'])); - - //Add class when available - if (!empty($session['p_class'])) { - $shared['class'] = $session['p_class']; - $description .= "\n\n".'Classe :'."\n".$session['p_class']; - } + //Persist cache item + $this->cache->commit(); - //Add contact when available - if (!empty($session['p_contact'])) { - $shared['contact'] = $session['p_contact']; - $description .= "\n\n".'Contact :'."\n".$session['p_contact']; - } + //With synchronized + //XXX: only store synchronized on run without caching + if ($synchronized) { + //Get google calendar + $googleCalendar = $this->doctrine->getRepository(GoogleCalendar::class)->findOneById($calendar['id']); - //Add donate when available - if (!empty($session['p_donate'])) { - $shared['donate'] = $session['p_donate']; - $description .= "\n\n".'Contribuer :'."\n".$session['p_donate']; - } + //Set synchronized + $googleCalendar->setSynchronized($synchronized); - //Add link when available - if (!empty($session['p_link'])) { - $shared['link'] = $session['p_link']; - $description .= "\n\n".'Site :'."\n".$session['p_link']; - } + //Queue google calendar save + $manager->persist($googleCalendar); - //Add profile when available - if (!empty($session['p_profile'])) { - $shared['profile'] = $session['p_profile']; - $description .= "\n\n".'Réseau social :'."\n".$session['p_profile']; - } + //Flush to get the ids + $manager->flush(); + } + } + } - //Locked session - if (!empty($session['locked']) && $events[$sessionId]) { - //With events - if (!empty($event = $events[$sessionId])) { - try { - //Delete the event - $googleCalendar->events->delete($token['calendar'], $event->getId()); - //Catch exception - } catch(\Google\Service\Exception $e) { - //Display exception - //TODO: handle codes here https://developers.google.com/calendar/api/guides/errors - echo 'Exception '.$e->getCode().':'.$e->getMessage().' in '.$e->getFile().' +'.$e->getLine()."\n"; - echo $e->getTraceAsString()."\n"; - - //Return failure - return self::FAILURE; - } - } - //Without event - } elseif (empty($events[$sessionId])) { - //Init event - $event = new \Google\Service\Calendar\Event( - [ - //TODO: replace 'airlibre' with $this->config['calendar']['prefix'] when possible with prefix validating [a-v0-9]{5,} - //XXX: see https://developers.google.com/calendar/api/v3/reference/events/insert#id - 'id' => $token['prefix'].$sessionId, - #'summary' => $session['au_pseudonym'].' '.$this->translator->trans('at '.$session['l_title']), - 'summary' => $source['title'], - #'description' => $markdown->convert(strip_tags($session['p_description'])), - 'description' => $description, - 'status' => empty($session['a_canceled'])?'confirmed':'cancelled', - 'location' => implode(' ', [$session['l_address'], $session['l_zipcode'], $session['l_city']]), - 'source' => $source, - 'extendedProperties' => [ - 'shared' => $shared - ], - //TODO: colorId ? - //TODO: attendees[] ? - 'start' => [ - 'dateTime' => $session['start']->format(\DateTime::ISO8601) - ], - 'end' => [ - 'dateTime' => $session['stop']->format(\DateTime::ISO8601) - ] - ] - ); - - try { - //Insert the event - $googleCalendar->events->insert($token['calendar'], $event); - //Catch exception - } catch(\Google\Service\Exception $e) { - //Display exception - //TODO: handle codes here https://developers.google.com/calendar/api/guides/errors - echo 'Exception '.$e->getCode().':'.$e->getMessage().' in '.$e->getFile().' +'.$e->getLine()."\n"; - echo $e->getTraceAsString()."\n"; - - //Return failure - return self::FAILURE; - } - // With event - } else { - //Set event - $event = $events[$sessionId]; + //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); - //With updated event - if ($session['updated'] >= (new \DateTime($event->getUpdated()))) { - //Set summary - #$event->setSummary($session['au_pseudonym'].' '.$this->translator->trans('at '.$session['l_title'])); - $event->setSummary($source['title']); + //Set sid + $sid = intval(substr($event->getId(), strlen($this->prefix))); - //Set description - $event->setDescription($description); + //Remove from events and cache events + unset($cacheEvents[$sid]); - //Set status - $event->setStatus(empty($session['a_canceled'])?'confirmed':'cancelled'); + //Set cache events + $this->item->set($cacheEvents); - //Set location - $event->setLocation(implode(' ', [$session['l_address'], $session['l_zipcode'], $session['l_city']])); + //Save cache item + $this->cache->saveDeferred($this->item); - //Get source - $eventSource = $event->getSource(); + //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) + ] + ); - //Update source title - $eventSource->setTitle($source['title']); + //Init location + $description = 'Emplacement :'."\n".$this->translator->trans($session['l_description']); + $shared['location'] = strip_tags($this->translator->trans($session['l_description'])); - //Update source url - $eventSource->setUrl($source['url']); + //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'])); + } - //Set source - #$event->setSource($source); + //Add class when available + if (!empty($session['p_class'])) { + $description .= "\n\n".'Classe :'."\n".$session['p_class']; + $shared['class'] = $session['p_class']; + } - //Get extended properties - $extendedProperties = $event->getExtendedProperties(); + //Add contact when available + if (!empty($session['p_contact'])) { + $description .= "\n\n".'Contact :'."\n".$session['p_contact']; + $shared['contact'] = $session['p_contact']; + } - //Update shared - $extendedProperties->setShared($shared); + //Add donate when available + if (!empty($session['p_donate'])) { + $description .= "\n\n".'Contribuer :'."\n".$session['p_donate']; + $shared['donate'] = $session['p_donate']; + } - //TODO: colorId ? - //TODO: attendees[] ? + //Add link when available + if (!empty($session['p_link'])) { + $description .= "\n\n".'Site :'."\n".$session['p_link']; + $shared['link'] = $session['p_link']; + } - //Set start - $start = $event->getStart(); + //Add profile when available + if (!empty($session['p_profile'])) { + $description .= "\n\n".'Réseau social :'."\n".$session['p_profile']; + $shared['profile'] = $session['p_profile']; + } - //Update start datetime - $start->setDateTime($session['start']->format(\DateTime::ISO8601)); + //Set properties + $properties = new EventExtendedProperties( + [ + //Set private property + 'private' => $private, + //Set shared property + 'shared' => $shared + ] + ); - //Set end - $end = $event->getEnd(); + //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()); - //Update stop datetime - $end->setDateTime($session['stop']->format(\DateTime::ISO8601)); + //Set description + $event->setDescription($description); - try { - //Update the event - $updatedEvent = $googleCalendar->events->update($token['calendar'], $event->getId(), $event); - //Catch exception - } catch(\Google\Service\Exception $e) { - //Display exception - //TODO: handle codes here https://developers.google.com/calendar/api/guides/errors - echo 'Exception '.$e->getCode().':'.$e->getMessage().' in '.$e->getFile().' +'.$e->getLine()."\n"; - echo $e->getTraceAsString()."\n"; + //Set status + $event->setStatus(empty($session['a_canceled'])?'confirmed':'cancelled'); - //Return failure - return self::FAILURE; - } - } + //Set location + $event->setLocation(implode(' ', [$session['l_address'], $session['l_zipcode'], $session['l_city']])); - //Drop from events array - unset($events[$sessionId]); - } - } + //Get source + #$eventSource = $event->getSource(); - //Remaining events to drop - foreach($events as $eventId => $event) { - //Non canceled events - if ($event->getStatus() == 'confirmed') { - try { - //Delete the event - $googleCalendar->events->delete($token['calendar'], $event->getId()); - //Catch exception - } catch(\Google\Service\Exception $e) { - //Display exception - //TODO: handle codes here https://developers.google.com/calendar/api/guides/errors - echo 'Exception '.$e->getCode().':'.$e->getMessage().' in '.$e->getFile().' +'.$e->getLine()."\n"; - echo $e->getTraceAsString()."\n"; - - //Return failure - return self::FAILURE; - } - } - } - } + //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 success - return self::SUCCESS; + //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']; } } diff --git a/Resources/config/packages/rapsysair.yaml b/Resources/config/packages/rapsysair.yaml index 9def36c..151ec25 100644 --- a/Resources/config/packages/rapsysair.yaml +++ b/Resources/config/packages/rapsysair.yaml @@ -357,10 +357,6 @@ services: tags: [ 'console.command' ] # Register calendar command Rapsys\AirBundle\Command\CalendarCommand: - arguments: [ '@doctrine', '%kernel.default_locale%', '@router', '@rapsyspack.slugger_util', '@translator', 'airlibre', 0, '%kernel.project_dir%/var/cache' ] - tags: [ 'console.command' ] - # Register calendar2 command - Rapsys\AirBundle\Command\Calendar2Command: arguments: [ '@doctrine', '%kernel.default_locale%', '@router', '@rapsyspack.slugger_util', '@translator', '@user.cache', '@google.client', '@twig.markdown.default' ] tags: [ 'console.command' ] # Register rekey command -- 2.41.0