]> Raphaël G. Git Repositories - airbundle/blob - Command/CalendarCommand.php
List dance sessions
[airbundle] / Command / CalendarCommand.php
1 <?php declare(strict_types=1);
2
3 /*
4 * This file is part of the Rapsys AirBundle package.
5 *
6 * (c) Raphaël Gertz <symfony@rapsys.eu>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12 namespace Rapsys\AirBundle\Command;
13
14 use Doctrine\Persistence\ManagerRegistry;
15
16 use Google\Client;
17 use Google\Service\Calendar;
18 use Google\Service\Calendar\Event;
19 use Google\Service\Calendar\EventExtendedProperties;
20 use Google\Service\Calendar\EventSource;
21
22 use Symfony\Component\Console\Input\InputInterface;
23 use Symfony\Component\Console\Output\OutputInterface;
24 use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
25 use Symfony\Component\Routing\RouterInterface;
26 use Symfony\Contracts\Cache\CacheInterface;
27 use Symfony\Contracts\Cache\ItemInterface;
28 use Symfony\Contracts\Translation\TranslatorInterface;
29
30 use Twig\Extra\Markdown\DefaultMarkdown;
31
32 use Rapsys\AirBundle\Command;
33 use Rapsys\AirBundle\Entity\GoogleCalendar;
34 use Rapsys\AirBundle\Entity\GoogleToken;
35 use Rapsys\AirBundle\Entity\Session;
36
37 use Rapsys\PackBundle\Util\SluggerUtil;
38
39 /**
40 * {@inheritdoc}
41 *
42 * Synchronize sessions in users' google calendar
43 */
44 class CalendarCommand extends Command {
45 /**
46 * Set description
47 *
48 * Shown with bin/console list
49 */
50 protected string $description = 'Synchronize sessions in users\' calendar';
51
52 /**
53 * Set help
54 *
55 * Shown with bin/console --help rapsysair:calendar
56 */
57 protected string $help = 'This command synchronize sessions in users\' google calendar';
58
59 /**
60 * Set domain
61 */
62 protected string $domain;
63
64 /**
65 * Set item
66 *
67 * Cache item instance
68 */
69 protected ItemInterface $item;
70
71 /**
72 * Set prefix
73 */
74 protected string $prefix;
75
76 /**
77 * Set service
78 *
79 * Google calendar instance
80 */
81 protected Calendar $service;
82
83 /**
84 * {@inheritdoc}
85 *
86 * @param CacheInterface $cache The cache instance
87 * @param Client $google The google client instance
88 * @param DefaultMarkdown $markdown The markdown instance
89 */
90 public function __construct(protected ManagerRegistry $doctrine, protected string $locale, protected RouterInterface $router, protected SluggerUtil $slugger, protected TranslatorInterface $translator, protected CacheInterface $cache, protected Client $google, protected DefaultMarkdown $markdown) {
91 //Call parent constructor
92 parent::__construct($this->doctrine, $this->locale, $this->router, $this->slugger, $this->translator);
93
94 //Replace google client redirect uri
95 $this->google->setRedirectUri($this->router->generate($this->google->getRedirectUri(), [], UrlGeneratorInterface::ABSOLUTE_URL));
96 }
97
98 /**
99 * Process the attribution
100 */
101 protected function execute(InputInterface $input, OutputInterface $output): int {
102 //Get domain
103 $this->domain = $this->router->getContext()->getHost();
104
105 //Get manager
106 $manager = $this->doctrine->getManager();
107
108 //Convert from any to latin, then to ascii and lowercase
109 $trans = \Transliterator::create('Any-Latin; Latin-ASCII; Lower()');
110
111 //Replace every non alphanumeric character by dash then trim dash
112 $this->prefix = preg_replace(['/(\.[a-z0-9]+)+$/', '/[^a-v0-9]+/'], '', $trans->transliterate($this->domain));
113
114 //With too short prefix
115 if ($this->prefix === null || strlen($this->prefix) < 4) {
116 //Throw domain exception
117 throw new \DomainException('Prefix too short: '.$this->prefix);
118 }
119
120 //Iterate on google tokens
121 foreach($tokens = $this->doctrine->getRepository(GoogleToken::class)->findAllIndexed() as $tid => $token) {
122 //Clear client cache before changing access token
123 //TODO: set a per token cache ?
124 $this->google->getCache()->clear();
125
126 //Set access token
127 $this->google->setAccessToken(
128 [
129 'access_token' => $token['access'],
130 'refresh_token' => $token['refresh'],
131 'created' => $token['created']->getTimestamp(),
132 'expires_in' => $token['expired']->getTimestamp() - (new \DateTime('now'))->getTimestamp()
133 ]
134 );
135
136 //With expired token
137 if ($this->google->isAccessTokenExpired()) {
138 //Refresh token
139 //TODO: better handle internal_failure
140 if (($gRefresh = $this->google->getRefreshToken()) && ($gToken = $this->google->fetchAccessTokenWithRefreshToken($gRefresh)) && empty($gToken['error'])) {
141 //Get google token
142 $googleToken = $this->doctrine->getRepository(GoogleToken::class)->findOneById($token['id']);
143
144 //Set access token
145 $googleToken->setAccess($gToken['access_token']);
146
147 //Set expires
148 $googleToken->setExpired(new \DateTime('+'.$gToken['expires_in'].' second'));
149
150 //Set refresh
151 $googleToken->setRefresh($gToken['refresh_token']);
152
153 //Queue google token save
154 $manager->persist($googleToken);
155
156 //Flush to get the ids
157 $manager->flush();
158 //Refresh failed
159 } else {
160 //Show error
161 //TODO: remove that and simply log internal failure ?
162 fprintf(STDERR, 'Unable to refresh token %d: %s', $token['id'], $gToken['error']?:'');
163
164 //TODO: warn user by mail ?
165
166 //Skip to next token
167 continue;
168 }
169 }
170
171 //Get google calendar service
172 $this->service = new Calendar($this->google);
173
174 //Iterate on google calendars
175 foreach($calendars = $token['calendars'] as $cid => $calendar) {
176 //Set start
177 $synchronized = null;
178
179 //Set cache key
180 $cacheKey = 'command.calendar.'.$this->slugger->short($calendar['mail']);
181
182 //XXX: TODO: remove DEBUG
183 #$this->cache->delete($cacheKey);
184
185 //Retrieve calendar events
186 try {
187 //Get events
188 $events = $this->cache->get(
189 //Cache key
190 //XXX: set to command.calendar.$mail
191 $cacheKey,
192 //Fetch mail calendar event list
193 function (ItemInterface $item) use ($calendar, &$synchronized): array {
194 //Expire after 1h
195 $item->expiresAfter(3600);
196
197 //Set synchronized
198 $synchronized = new \DateTime('now');
199
200 //Init events
201 $events = [];
202
203 //Set filters
204 //TODO: add a filter to only retrieve
205 $filters = [
206 //XXX: every event even deleted one to be able to update them
207 'showDeleted' => true,
208 //XXX: every instances
209 'singleEvents' => false,
210 //XXX: select only domain events
211 'privateExtendedProperty' => 'domain='.$this->domain
212 #TODO: restrict events even more by time or updated datetime ? (-1 week to +2 week)
213 //TODO: fetch events one day before and one day after to avoid triggering double insert duplicate key 409 errors :=) on google
214 #'timeMin' => $period->getStartDate()->format(\DateTime::ISO8601),
215 #'timeMax' => $period->getEndDate()->format(\DateTime::ISO8601)
216 /*, 'iCalUID' => 'airlibre/?????'*//*'orderBy' => 'startTime', */
217 //updatedMin => new \DateTime('-1 week') ?
218 ];
219
220 //Set page token
221 $pageToken = null;
222
223 //Iterate until next page token is null
224 do {
225 //Get calendar events list
226 //XXX: see vendor/google/apiclient-services/src/Calendar/Resource/Events.php +289
227 $eventList = $this->service->events->listEvents($calendar['mail'], ['pageToken' => $pageToken]+$filters);
228
229 //Iterate on items
230 foreach($eventList->getItems() as $event) {
231 //With extended properties
232 if (($properties = $event->getExtendedProperties()) && ($private = $properties->getPrivate()) && isset($private['id']) && ($id = $private['id']) && isset($private['domain']) && $private['domain'] == $this->domain) {
233 //Add event
234 $events[$id] = $event;
235 //XXX: 3rd party events without matching prefix and id are skipped
236 #} else {
237 # #echo 'Skipping '.$event->getId().':'.$event->getSummary()."\n";
238 # echo 'Skipping '.$id.':'.$event->getSummary()."\n";
239 }
240 }
241 } while ($pageToken = $eventList->getNextPageToken());
242
243 //Return events
244 return $events;
245 }
246 );
247 //Catch exception
248 } catch(\Google\Service\Exception $e) {
249 //With 401 or code
250 //XXX: see https://cloud.google.com/apis/design/errors
251 if ($e->getCode() == 401 || $e->getCode() == 403) {
252 //Show error
253 fprintf(STDERR, 'Unable to list calendar %d events: %s', $calendar['id'], $e->getMessage()?:'');
254
255 //TODO: warn user by mail ?
256
257 //Skip to next token
258 continue;
259 }
260
261 //Throw error
262 throw new \LogicException('Calendar event list failed', 0, $e);
263 }
264
265 //Store cache item
266 $this->item = $this->cache->getItem($cacheKey);
267
268 //Iterate on sessions to update
269 foreach($this->doctrine->getRepository(Session::class)->findAllByUserIdSynchronized($token['uid'], $calendar['synchronized']) as $session) {
270 //Start exception catching
271 try {
272 //Without event
273 if (!isset($events[$session['id']])) {
274 //Insert event
275 $this->insert($calendar['mail'], $session);
276 //With locked session
277 } elseif (($event = $events[$session['id']]) && !empty($session['locked'])) {
278 //Delete event
279 $sid = $this->delete($calendar['mail'], $event);
280
281 //Drop from events array
282 unset($events[$sid]);
283 //With event to update
284 } elseif ($session['modified'] > (new \DateTime($event->getUpdated()))) {
285 //Update event
286 $sid = $this->update($calendar['mail'], $event, $session);
287
288 //Drop from events array
289 unset($events[$sid]);
290 }
291 //Catch exception
292 } catch(\Google\Service\Exception $e) {
293 //Identifier already exists
294 if ($e->getCode() == 409) {
295 //Get calendar event
296 //XXX: see vendor/google/apiclient-services/src/Calendar/Resource/Events.php +81
297 $event = $this->service->events->get($calendar['mail'], $this->prefix.$session['id']);
298
299 //Update required
300 if ($session['modified'] > (new \DateTime($event->getUpdated()))) {
301 //Update event
302 $sid = $this->update($calendar['mail'], $event, $session);
303
304 //Drop from events array
305 unset($events[$sid]);
306 }
307 //TODO: handle other codes gracefully ? (503 & co)
308 //Other errors
309 } else {
310 //Throw error
311 throw new \LogicException(sprintf('Calendar %s event %s operation failed', $calendar, $this->prefix.$session['id']), 0, $e);
312 }
313 }
314
315 //Sleep
316 usleep(300000);
317 }
318
319 //Get all sessions
320 $sessions = $this->doctrine->getRepository(Session::class)->findAllByUserIdSynchronized($token['uid']);
321
322 //Remaining events to drop
323 foreach($events as $eid => $event) {
324 //With events updated since last synchronized
325 if ($event->getStatus() == 'confirmed' && (new \DateTime($event->getUpdated())) > $calendar['synchronized']) {
326 //TODO: Add a try/catch here to handle error codes gracefully (503 & co) ?
327 //With event to update
328 if (isset($sessions[$eid]) && ($session = $sessions[$eid]) && empty($session['locked'])) {
329 //Update event
330 $sid = $this->update($calendar['mail'], $event, $session);
331
332 //Drop from events array
333 unset($events[$sid]);
334 //With locked or unknown session
335 } else {
336 //Delete event
337 $sid = $this->delete($calendar['mail'], $event);
338
339 //Drop from events array
340 unset($events[$sid]);
341 }
342 }
343 }
344
345 //Persist cache item
346 $this->cache->commit();
347
348 //With synchronized
349 //XXX: only store synchronized on run without caching
350 if ($synchronized) {
351 //Get google calendar
352 $googleCalendar = $this->doctrine->getRepository(GoogleCalendar::class)->findOneById($calendar['id']);
353
354 //Set synchronized
355 $googleCalendar->setSynchronized($synchronized);
356
357 //Queue google calendar save
358 $manager->persist($googleCalendar);
359
360 //Flush to get the ids
361 $manager->flush();
362 }
363 }
364 }
365
366 //Return success
367 return self::SUCCESS;
368 }
369
370 /**
371 * Delete event
372 *
373 * @param string $calendar The calendar mail
374 * @param Event $event The google event instance
375 * @return void
376 */
377 function delete(string $calendar, Event $event): int {
378 //Get cache events
379 $cacheEvents = $this->item->get();
380
381 //Get event id
382 $eid = $event->getId();
383
384 //Delete the event
385 $this->service->events->delete($calendar, $eid);
386
387 //Set sid
388 $sid = intval(substr($event->getId(), strlen($this->prefix)));
389
390 //Remove from events and cache events
391 unset($cacheEvents[$sid]);
392
393 //Set cache events
394 $this->item->set($cacheEvents);
395
396 //Save cache item
397 $this->cache->saveDeferred($this->item);
398
399 //Return session id
400 return $sid;
401 }
402
403 /**
404 * Fill event
405 *
406 * TODO: add domain based/calendar mail specific templates ?
407 *
408 * @param array $session The session instance
409 * @param ?Event $event The event instance
410 * @return Event The filled event
411 */
412 function fill(array $session, ?Event $event = null): Event {
413 //Init private properties
414 $private = [
415 'id' => $session['id'],
416 'domain' => $this->domain,
417 'updated' => $session['modified']->format(\DateTime::ISO8601)
418 ];
419
420 //Init shared properties
421 //TODO: validate for constraints here ??? https://developers.google.com/calendar/api/guides/extended-properties
422 //TODO: drop shared as unused ???
423 $shared = [
424 'gps' => $session['l_latitude'].','.$session['l_longitude']
425 ];
426
427 //Init source
428 $source = new EventSource(
429 [
430 'title' => $this->translator->trans('%dance% %id% by %pseudonym%', ['%id%' => $session['id'], '%dance%' => $this->translator->trans($session['ad_name'].' '.lcfirst($session['ad_type'])), '%pseudonym%' => $session['au_pseudonym']]).' '.$this->translator->trans('at '.$session['l_title']),
431 'url' => $this->router->generate('rapsysair_session_view', ['id' => $session['id'], 'location' => $this->slugger->slug($this->translator->trans($session['l_title'])), 'dance' => $this->slugger->slug($this->translator->trans($session['ad_name'].' '.lcfirst($session['ad_type']))), 'user' => $this->slugger->slug($session['au_pseudonym'])], UrlGeneratorInterface::ABSOLUTE_URL)
432 ]
433 );
434
435 //Init location
436 $description = 'Emplacement :'."\n".$this->translator->trans($session['l_description']);
437 $shared['location'] = strip_tags($this->translator->trans($session['l_description']));
438
439 //Add description when available
440 if(!empty($session['p_description'])) {
441 $description .= "\n\n".'Description :'."\n".strip_tags(preg_replace('!<a href="([^"]+)"(?: title="[^"]+")?'.'>([^<]+)</a>!', '\1', $this->markdown->convert(strip_tags($session['p_description']))));
442 $shared['description'] = $this->markdown->convert(strip_tags($session['p_description']));
443 }
444
445 //Add class when available
446 if (!empty($session['p_class'])) {
447 $description .= "\n\n".'Classe :'."\n".$session['p_class'];
448 $shared['class'] = $session['p_class'];
449 }
450
451 //Add contact when available
452 if (!empty($session['p_contact'])) {
453 $description .= "\n\n".'Contact :'."\n".$session['p_contact'];
454 $shared['contact'] = $session['p_contact'];
455 }
456
457 //Add donate when available
458 if (!empty($session['p_donate'])) {
459 $description .= "\n\n".'Contribuer :'."\n".$session['p_donate'];
460 $shared['donate'] = $session['p_donate'];
461 }
462
463 //Add link when available
464 if (!empty($session['p_link'])) {
465 $description .= "\n\n".'Site :'."\n".$session['p_link'];
466 $shared['link'] = $session['p_link'];
467 }
468
469 //Add profile when available
470 if (!empty($session['p_profile'])) {
471 $description .= "\n\n".'Réseau social :'."\n".$session['p_profile'];
472 $shared['profile'] = $session['p_profile'];
473 }
474
475 //Set properties
476 $properties = new EventExtendedProperties(
477 [
478 //Set private property
479 'private' => $private,
480 //Set shared property
481 'shared' => $shared
482 ]
483 );
484
485 //Without event
486 if ($event === null) {
487 //Init event
488 $event = new Event(
489 [
490 //Id must match /^[a-v0-9]{5,}$/
491 //XXX: see https://developers.google.com/calendar/api/v3/reference/events/insert#id
492 'id' => $this->prefix.$session['id'],
493 'summary' => $source->getTitle(),
494 'description' => $description,
495 'status' => empty($session['a_canceled'])?'confirmed':'cancelled',
496 'location' => implode(' ', [$session['l_address'], $session['l_zipcode'], $session['l_city']]),
497 'source' => $source,
498 'extendedProperties' => $properties,
499 //TODO: colorId ?
500 //TODO: attendees[] ?
501 'start' => [
502 'dateTime' => $session['start']->format(\DateTime::ISO8601)
503 ],
504 'end' => [
505 'dateTime' => $session['stop']->format(\DateTime::ISO8601)
506 ]
507 ]
508 );
509 //With event
510 } else {
511 //Set summary
512 $event->setSummary($source->getTitle());
513
514 //Set description
515 $event->setDescription($description);
516
517 //Set status
518 $event->setStatus(empty($session['a_canceled'])?'confirmed':'cancelled');
519
520 //Set location
521 $event->setLocation(implode(' ', [$session['l_address'], $session['l_zipcode'], $session['l_city']]));
522
523 //Get source
524 #$eventSource = $event->getSource();
525
526 //Update source title
527 #$eventSource->setTitle($source->getTitle());
528
529 //Update source url
530 #$eventSource->setUrl($source->getUrl());
531
532 //Set source
533 $event->setSource($source);
534
535 //Get extended properties
536 #$extendedProperties = $event->getExtendedProperties();
537
538 //Update private
539 #$extendedProperties->setPrivate($properties->getPrivate());
540
541 //Update shared
542 #$extendedProperties->setShared($properties->getShared());
543
544 //Set properties
545 $event->setExtendedProperties($properties);
546
547 //TODO: colorId ?
548 //TODO: attendees[] ?
549
550 //Set start
551 $start = $event->getStart();
552
553 //Update start datetime
554 $start->setDateTime($session['start']->format(\DateTime::ISO8601));
555
556 //Set end
557 $end = $event->getEnd();
558
559 //Update stop datetime
560 $end->setDateTime($session['stop']->format(\DateTime::ISO8601));
561 }
562
563 //Return event
564 return $event;
565 }
566
567 /**
568 * Insert event
569 *
570 * @param string $calendar The calendar mail
571 * @param array $session The session instance
572 * @return void
573 */
574 function insert(string $calendar, array $session): void {
575 //Get event
576 $event = $this->fill($session);
577
578 //Get cache events
579 $cacheEvents = $this->item->get();
580
581 //Insert in cache event
582 $cacheEvents[$session['id']] = $this->service->events->insert($calendar, $event);
583
584 //Set cache events
585 $this->item->set($cacheEvents);
586
587 //Save cache item
588 $this->cache->saveDeferred($this->item);
589 }
590
591 /**
592 * Update event
593 *
594 * @param string $calendar The calendar mail
595 * @param Event $event The google event instance
596 * @param array $session The session instance
597 * @return int The session id
598 */
599 function update(string $calendar, Event $event, array $session): int {
600 //Get event
601 $event = $this->fill($session, $event);
602
603 //Get cache events
604 $cacheEvents = $this->item->get();
605
606 //Update in cache events
607 $cacheEvents[$session['id']] = $this->service->events->update($calendar, $event->getId(), $event);
608
609 //Set cache events
610 $this->item->set($cacheEvents);
611
612 //Save cache item
613 $this->cache->saveDeferred($this->item);
614
615 //Return session id
616 return $session['id'];
617 }
618 }