]> Raphaël G. Git Repositories - airbundle/blob - Command/CalendarCommand.php
Rename rapsysair:calendar2 command to rapsysair:calendar
[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 if (($gRefresh = $this->google->getRefreshToken()) && ($gToken = $this->google->fetchAccessTokenWithRefreshToken($gRefresh)) && empty($gToken['error'])) {
140 //Get google token
141 $googleToken = $this->doctrine->getRepository(GoogleToken::class)->findOneById($token['id']);
142
143 //Set access token
144 $googleToken->setAccess($gToken['access_token']);
145
146 //Set expires
147 $googleToken->setExpired(new \DateTime('+'.$gToken['expires_in'].' second'));
148
149 //Set refresh
150 $googleToken->setRefresh($gToken['refresh_token']);
151
152 //Queue google token save
153 $manager->persist($googleToken);
154
155 //Flush to get the ids
156 $manager->flush();
157 //Refresh failed
158 } else {
159 //Show error
160 fprintf(STDERR, 'Unable to refresh token %d: %s', $token['id'], $gToken['error']?:'');
161
162 //TODO: warn user by mail ?
163
164 //Skip to next token
165 continue;
166 }
167 }
168
169 //Get google calendar service
170 $this->service = new Calendar($this->google);
171
172 //Iterate on google calendars
173 foreach($calendars = $token['calendars'] as $cid => $calendar) {
174 //Set start
175 $synchronized = null;
176
177 //Set cache key
178 $cacheKey = 'command.calendar.'.$this->slugger->short($calendar['mail']);
179
180 //XXX: TODO: remove DEBUG
181 #$this->cache->delete($cacheKey);
182
183 //Retrieve calendar events
184 try {
185 //Get events
186 $events = $this->cache->get(
187 //Cache key
188 //XXX: set to command.calendar.$mail
189 $cacheKey,
190 //Fetch mail calendar event list
191 function (ItemInterface $item) use ($calendar, &$synchronized): array {
192 //Expire after 1h
193 $item->expiresAfter(3600);
194
195 //Set synchronized
196 $synchronized = new \DateTime('now');
197
198 //Init events
199 $events = [];
200
201 //Set filters
202 //TODO: add a filter to only retrieve
203 $filters = [
204 //XXX: every event even deleted one to be able to update them
205 'showDeleted' => true,
206 //XXX: every instances
207 'singleEvents' => false,
208 //XXX: select only domain events
209 'privateExtendedProperty' => 'domain='.$this->domain
210 #TODO: restrict events even more by time or updated datetime ? (-1 week to +2 week)
211 //TODO: fetch events one day before and one day after to avoid triggering double insert duplicate key 409 errors :=) on google
212 #'timeMin' => $period->getStartDate()->format(\DateTime::ISO8601),
213 #'timeMax' => $period->getEndDate()->format(\DateTime::ISO8601)
214 /*, 'iCalUID' => 'airlibre/?????'*//*'orderBy' => 'startTime', */
215 //updatedMin => new \DateTime('-1 week') ?
216 ];
217
218 //Set page token
219 $pageToken = null;
220
221 //Iterate until next page token is null
222 do {
223 //Get calendar events list
224 //XXX: see vendor/google/apiclient-services/src/Calendar/Resource/Events.php +289
225 $eventList = $this->service->events->listEvents($calendar['mail'], ['pageToken' => $pageToken]+$filters);
226
227 //Iterate on items
228 foreach($eventList->getItems() as $event) {
229 //With extended properties
230 if (($properties = $event->getExtendedProperties()) && ($private = $properties->getPrivate()) && isset($private['id']) && ($id = $private['id']) && isset($private['domain']) && $private['domain'] == $this->domain) {
231 //Add event
232 $events[$id] = $event;
233 //XXX: 3rd party events without matching prefix and id are skipped
234 #} else {
235 # #echo 'Skipping '.$event->getId().':'.$event->getSummary()."\n";
236 # echo 'Skipping '.$id.':'.$event->getSummary()."\n";
237 }
238 }
239 } while ($pageToken = $eventList->getNextPageToken());
240
241 //Return events
242 return $events;
243 }
244 );
245 //Catch exception
246 } catch(\Google\Service\Exception $e) {
247 //With 401 or code
248 //XXX: see https://cloud.google.com/apis/design/errors
249 if ($e->getCode() == 401 || $e->getCode() == 403) {
250 //Show error
251 fprintf(STDERR, 'Unable to list calendar %d events: %s', $calendar['id'], $e->getMessage()?:'');
252
253 //TODO: warn user by mail ?
254
255 //Skip to next token
256 continue;
257 }
258
259 //Throw error
260 throw new \LogicException('Calendar event list failed', 0, $e);
261 }
262
263 //Store cache item
264 $this->item = $this->cache->getItem($cacheKey);
265
266 //Iterate on sessions to update
267 foreach($this->doctrine->getRepository(Session::class)->findAllByUserIdSynchronized($token['uid'], $calendar['synchronized']) as $session) {
268 //Start exception catching
269 try {
270 //Without event
271 if (!isset($events[$session['id']])) {
272 //Insert event
273 $this->insert($calendar['mail'], $session);
274 //With locked session
275 } elseif (($event = $events[$session['id']]) && !empty($session['locked'])) {
276 //Delete event
277 $sid = $this->delete($calendar['mail'], $event);
278
279 //Drop from events array
280 unset($events[$sid]);
281 //With event to update
282 } elseif ($session['modified'] > (new \DateTime($event->getUpdated()))) {
283 //Update event
284 $sid = $this->update($calendar['mail'], $event, $session);
285
286 //Drop from events array
287 unset($events[$sid]);
288 }
289 //Catch exception
290 } catch(\Google\Service\Exception $e) {
291 //Identifier already exists
292 if ($e->getCode() == 409) {
293 //Get calendar event
294 //XXX: see vendor/google/apiclient-services/src/Calendar/Resource/Events.php +81
295 $event = $this->service->events->get($calendar['mail'], $this->prefix.$session['id']);
296
297 //Update required
298 if ($session['modified'] > (new \DateTime($event->getUpdated()))) {
299 //Update event
300 $sid = $this->update($calendar['mail'], $event, $session);
301
302 //Drop from events array
303 unset($events[$sid]);
304 }
305 //TODO: handle other codes gracefully ? (503 & co)
306 //Other errors
307 } else {
308 //Throw error
309 throw new \LogicException(sprintf('Calendar %s event %s operation failed', $calendar, $this->prefix.$session['id']), 0, $e);
310 }
311 }
312 }
313
314 //Get all sessions
315 $sessions = $this->doctrine->getRepository(Session::class)->findAllByUserIdSynchronized($token['uid']);
316
317 //Remaining events to drop
318 foreach($events as $eid => $event) {
319 //With events updated since last synchronized
320 if ($event->getStatus() == 'confirmed' && (new \DateTime($event->getUpdated())) > $calendar['synchronized']) {
321 //TODO: Add a try/catch here to handle error codes gracefully (503 & co) ?
322 //With event to update
323 if (isset($sessions[$eid]) && ($session = $sessions[$eid]) && empty($session['locked'])) {
324 //Update event
325 $sid = $this->update($calendar['mail'], $event, $session);
326
327 //Drop from events array
328 unset($events[$sid]);
329 //With locked or unknown session
330 } else {
331 //Delete event
332 $sid = $this->delete($calendar['mail'], $event);
333
334 //Drop from events array
335 unset($events[$sid]);
336 }
337 }
338 }
339
340 //Persist cache item
341 $this->cache->commit();
342
343 //With synchronized
344 //XXX: only store synchronized on run without caching
345 if ($synchronized) {
346 //Get google calendar
347 $googleCalendar = $this->doctrine->getRepository(GoogleCalendar::class)->findOneById($calendar['id']);
348
349 //Set synchronized
350 $googleCalendar->setSynchronized($synchronized);
351
352 //Queue google calendar save
353 $manager->persist($googleCalendar);
354
355 //Flush to get the ids
356 $manager->flush();
357 }
358 }
359 }
360
361 //Return success
362 return self::SUCCESS;
363 }
364
365 /**
366 * Delete event
367 *
368 * @param string $calendar The calendar mail
369 * @param Event $event The google event instance
370 * @return void
371 */
372 function delete(string $calendar, Event $event): int {
373 //Get cache events
374 $cacheEvents = $this->item->get();
375
376 //Get event id
377 $eid = $event->getId();
378
379 //Delete the event
380 $this->service->events->delete($calendar, $eid);
381
382 //Set sid
383 $sid = intval(substr($event->getId(), strlen($this->prefix)));
384
385 //Remove from events and cache events
386 unset($cacheEvents[$sid]);
387
388 //Set cache events
389 $this->item->set($cacheEvents);
390
391 //Save cache item
392 $this->cache->saveDeferred($this->item);
393
394 //Return session id
395 return $sid;
396 }
397
398 /**
399 * Fill event
400 *
401 * TODO: add domain based/calendar mail specific templates ?
402 *
403 * @param array $session The session instance
404 * @param ?Event $event The event instance
405 * @return Event The filled event
406 */
407 function fill(array $session, ?Event $event = null): Event {
408 //Init private properties
409 $private = [
410 'id' => $session['id'],
411 'domain' => $this->domain,
412 'updated' => $session['modified']->format(\DateTime::ISO8601)
413 ];
414
415 //Init shared properties
416 //TODO: validate for constraints here ??? https://developers.google.com/calendar/api/guides/extended-properties
417 //TODO: drop shared as unused ???
418 $shared = [
419 'gps' => $session['l_latitude'].','.$session['l_longitude']
420 ];
421
422 //Init source
423 $source = new EventSource(
424 [
425 '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']),
426 '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)
427 ]
428 );
429
430 //Init location
431 $description = 'Emplacement :'."\n".$this->translator->trans($session['l_description']);
432 $shared['location'] = strip_tags($this->translator->trans($session['l_description']));
433
434 //Add description when available
435 if(!empty($session['p_description'])) {
436 $description .= "\n\n".'Description :'."\n".strip_tags(preg_replace('!<a href="([^"]+)"(?: title="[^"]+")?'.'>([^<]+)</a>!', '\1', $this->markdown->convert(strip_tags($session['p_description']))));
437 $shared['description'] = $this->markdown->convert(strip_tags($session['p_description']));
438 }
439
440 //Add class when available
441 if (!empty($session['p_class'])) {
442 $description .= "\n\n".'Classe :'."\n".$session['p_class'];
443 $shared['class'] = $session['p_class'];
444 }
445
446 //Add contact when available
447 if (!empty($session['p_contact'])) {
448 $description .= "\n\n".'Contact :'."\n".$session['p_contact'];
449 $shared['contact'] = $session['p_contact'];
450 }
451
452 //Add donate when available
453 if (!empty($session['p_donate'])) {
454 $description .= "\n\n".'Contribuer :'."\n".$session['p_donate'];
455 $shared['donate'] = $session['p_donate'];
456 }
457
458 //Add link when available
459 if (!empty($session['p_link'])) {
460 $description .= "\n\n".'Site :'."\n".$session['p_link'];
461 $shared['link'] = $session['p_link'];
462 }
463
464 //Add profile when available
465 if (!empty($session['p_profile'])) {
466 $description .= "\n\n".'Réseau social :'."\n".$session['p_profile'];
467 $shared['profile'] = $session['p_profile'];
468 }
469
470 //Set properties
471 $properties = new EventExtendedProperties(
472 [
473 //Set private property
474 'private' => $private,
475 //Set shared property
476 'shared' => $shared
477 ]
478 );
479
480 //Without event
481 if ($event === null) {
482 //Init event
483 $event = new Event(
484 [
485 //Id must match /^[a-v0-9]{5,}$/
486 //XXX: see https://developers.google.com/calendar/api/v3/reference/events/insert#id
487 'id' => $this->prefix.$session['id'],
488 'summary' => $source->getTitle(),
489 'description' => $description,
490 'status' => empty($session['a_canceled'])?'confirmed':'cancelled',
491 'location' => implode(' ', [$session['l_address'], $session['l_zipcode'], $session['l_city']]),
492 'source' => $source,
493 'extendedProperties' => $properties,
494 //TODO: colorId ?
495 //TODO: attendees[] ?
496 'start' => [
497 'dateTime' => $session['start']->format(\DateTime::ISO8601)
498 ],
499 'end' => [
500 'dateTime' => $session['stop']->format(\DateTime::ISO8601)
501 ]
502 ]
503 );
504 //With event
505 } else {
506 //Set summary
507 $event->setSummary($source->getTitle());
508
509 //Set description
510 $event->setDescription($description);
511
512 //Set status
513 $event->setStatus(empty($session['a_canceled'])?'confirmed':'cancelled');
514
515 //Set location
516 $event->setLocation(implode(' ', [$session['l_address'], $session['l_zipcode'], $session['l_city']]));
517
518 //Get source
519 #$eventSource = $event->getSource();
520
521 //Update source title
522 #$eventSource->setTitle($source->getTitle());
523
524 //Update source url
525 #$eventSource->setUrl($source->getUrl());
526
527 //Set source
528 $event->setSource($source);
529
530 //Get extended properties
531 #$extendedProperties = $event->getExtendedProperties();
532
533 //Update private
534 #$extendedProperties->setPrivate($properties->getPrivate());
535
536 //Update shared
537 #$extendedProperties->setShared($properties->getShared());
538
539 //Set properties
540 $event->setExtendedProperties($properties);
541
542 //TODO: colorId ?
543 //TODO: attendees[] ?
544
545 //Set start
546 $start = $event->getStart();
547
548 //Update start datetime
549 $start->setDateTime($session['start']->format(\DateTime::ISO8601));
550
551 //Set end
552 $end = $event->getEnd();
553
554 //Update stop datetime
555 $end->setDateTime($session['stop']->format(\DateTime::ISO8601));
556 }
557
558 //Return event
559 return $event;
560 }
561
562 /**
563 * Insert event
564 *
565 * @param string $calendar The calendar mail
566 * @param array $session The session instance
567 * @return void
568 */
569 function insert(string $calendar, array $session): void {
570 //Get event
571 $event = $this->fill($session);
572
573 //Get cache events
574 $cacheEvents = $this->item->get();
575
576 //Insert in cache event
577 $cacheEvents[$session['id']] = $this->service->events->insert($calendar, $event);
578
579 //Set cache events
580 $this->item->set($cacheEvents);
581
582 //Save cache item
583 $this->cache->saveDeferred($this->item);
584 }
585
586 /**
587 * Update event
588 *
589 * @param string $calendar The calendar mail
590 * @param Event $event The google event instance
591 * @param array $session The session instance
592 * @return int The session id
593 */
594 function update(string $calendar, Event $event, array $session): int {
595 //Get event
596 $event = $this->fill($session, $event);
597
598 //Get cache events
599 $cacheEvents = $this->item->get();
600
601 //Update in cache events
602 $cacheEvents[$session['id']] = $this->service->events->update($calendar, $event->getId(), $event);
603
604 //Set cache events
605 $this->item->set($cacheEvents);
606
607 //Save cache item
608 $this->cache->saveDeferred($this->item);
609
610 //Return session id
611 return $session['id'];
612 }
613 }