]> Raphaël G. Git Repositories - airbundle/blob - Command/CalendarCommand.php
Remove body template
[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 use Symfony\Component\Cache\Adapter\FilesystemAdapter;
16 use Symfony\Component\Console\Command\Command;
17 use Symfony\Component\Console\Input\InputInterface;
18 use Symfony\Component\Console\Output\OutputInterface;
19 use Symfony\Component\DependencyInjection\ContainerInterface;
20 use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
21 use Symfony\Component\Routing\RouterInterface;
22 use Symfony\Contracts\Translation\TranslatorInterface;
23 use Twig\Extra\Markdown\DefaultMarkdown;
24
25 use Rapsys\AirBundle\Command;
26 use Rapsys\AirBundle\Entity\Session;
27
28 use Rapsys\PackBundle\Util\SluggerUtil;
29
30 class CalendarCommand extends Command {
31 //Set failure constant
32 const FAILURE = 1;
33
34 ///Set success constant
35 const SUCCESS = 0;
36
37 /**
38 * Doctrine instance
39 *
40 * @var ManagerRegistry
41 */
42 protected ManagerRegistry $doctrine;
43
44 ///Router
45 protected RouterInterface $router;
46
47 ///Slugger
48 protected SluggerUtil $slugger;
49
50 ///Translator instance
51 protected TranslatorInterface $translator;
52
53 ///Locale
54 protected $locale;
55
56 ///Lifetime string
57 protected $lifetime;
58
59 ///Namespace string
60 protected $namespace;
61
62 ///Path string
63 protected $path;
64
65 /**
66 * Creates new calendar command
67 *
68 * @param ManagerRegistry $doctrine The doctrine instance
69 * @param RouterInterface $router The router instance
70 * @param SluggerUtil $slugger The slugger instance
71 * @param TranslatorInterface $translator The translator instance
72 * @param string $namespace The cache namespace
73 * @param int $lifetime The cache lifetime
74 * @param string $path The cache path
75 * @param string $locale The default locale
76 */
77 public function __construct(ManagerRegistry $doctrine, RouterInterface $router, SluggerUtil $slugger, TranslatorInterface $translator, string $namespace, int $lifetime, string $path, string $locale) {
78 //Call parent constructor
79 parent::__construct();
80
81 //Store doctrine
82 $this->doctrine = $doctrine;
83
84 //Set lifetime
85 $this->lifetime = $lifetime;
86
87 //Set namespace
88 $this->namespace = $namespace;
89
90 //Set path
91 $this->path = $path;
92
93 //Store router
94 $this->router = $router;
95
96 //Retrieve slugger
97 $this->slugger = $slugger;
98
99 //Get router context
100 $context = $this->router->getContext();
101
102 //Set host
103 $context->setHost('airlibre.eu');
104
105 //Set scheme
106 $context->setScheme('https');
107
108 //Set the translator
109 $this->translator = $translator;
110 }
111
112 ///Configure attribute command
113 protected function configure() {
114 //Configure the class
115 $this
116 //Set name
117 ->setName('rapsysair:calendar')
118 //Set description shown with bin/console list
119 ->setDescription('Synchronize sessions in calendar')
120 //Set description shown with bin/console --help airlibre:attribute
121 ->setHelp('This command synchronize sessions in google calendar');
122 }
123
124 ///Process the attribution
125 protected function execute(InputInterface $input, OutputInterface $output): int {
126 //Compute period
127 $period = new \DatePeriod(
128 //Start from last week
129 new \DateTime('-1 week'),
130 //Iterate on each day
131 new \DateInterval('P1D'),
132 //End with next 2 week
133 new \DateTime('+2 week')
134 );
135
136 //Retrieve events to update
137 $sessions = $this->doctrine->getRepository(Session::class)->fetchAllByDatePeriod($period, $this->locale);
138
139 //Markdown converted instance
140 $markdown = new DefaultMarkdown;
141
142 //Retrieve cache object
143 //XXX: by default stored in /tmp/symfony-cache/@/W/3/6SEhFfeIW4UMDlAII+Dg
144 //XXX: stored in %kernel.project_dir%/var/cache/airlibre/0/P/IA20X0K4dkMd9-+Ohp9Q
145 $cache = new FilesystemAdapter($this->namespace, $this->lifetime, $this->path);
146
147 //Retrieve calendars
148 $cacheCalendars = $cache->getItem('calendars');
149
150 //Without calendars
151 if (!$cacheCalendars->isHit()) {
152 //Return failure
153 return self::FAILURE;
154 }
155
156 //Retrieve calendars
157 $calendars = $cacheCalendars->get();
158
159 //XXX: calendars content
160 #var_export($calendars);
161
162 //Check expired token
163 foreach($calendars as $clientId => $client) {
164 //Get google client
165 $googleClient = new \Google\Client(['application_name' => $client['project'], 'client_id' => $clientId, 'client_secret' => $client['secret'], 'redirect_uri' => $client['redirect']]);
166
167 //Iterate on each tokens
168 foreach($client['tokens'] as $tokenId => $token) {
169 //Set token
170 $googleClient->setAccessToken(
171 [
172 'access_token' => $tokenId,
173 'refresh_token' => $token['refresh'],
174 'expires_in' => $token['expire'],
175 'scope' => $token['scope'],
176 'token_type' => $token['type'],
177 'created' => $token['created']
178 ]
179 );
180
181 //With expired token
182 if ($exp = $googleClient->isAccessTokenExpired()) {
183 //Refresh token
184 if (($refreshToken = $googleClient->getRefreshToken()) && ($googleToken = $googleClient->fetchAccessTokenWithRefreshToken($refreshToken)) && empty($googleToken['error'])) {
185 //Add refreshed token
186 $calendars[$clientId]['tokens'][$googleToken['access_token']] = [
187 'calendar' => $token['calendar'],
188 'prefix' => $token['prefix'],
189 'refresh' => $googleToken['refresh_token'],
190 'expire' => $googleToken['expires_in'],
191 'scope' => $googleToken['scope'],
192 'type' => $googleToken['token_type'],
193 'created' => $googleToken['created']
194 ];
195
196 //Remove old token
197 unset($calendars[$clientId]['tokens'][$tokenId]);
198 } else {
199 //Drop token
200 unset($calendars[$clientId]['tokens'][$tokenId]);
201
202 //Without tokens
203 if (empty($calendars[$clientId]['tokens'])) {
204 //Drop client
205 unset($calendars[$clientId]);
206 }
207
208 //Save calendars
209 $cacheCalendars->set($calendars);
210
211 //Save calendar
212 $cache->save($cacheCalendars);
213
214 //Drop token and report
215 //XXX: submit app to avoid expiration
216 //XXX: see https://console.cloud.google.com/apis/credentials/consent?project=calendar-317315
217 echo 'Token '.$tokenId.' for calendar '.$token['calendar'].' has expired and is not refreshable'."\n";
218
219 //Return failure
220 //XXX: we want that mail and stop here
221 return self::FAILURE;
222 }
223 }
224 }
225 }
226
227 //Save calendars
228 $cacheCalendars->set($calendars);
229
230 //Save calendar
231 $cache->save($cacheCalendars);
232
233 //Iterate on each calendar client
234 foreach($calendars as $clientId => $client) {
235 //Get google client
236 $googleClient = new \Google\Client(['application_name' => $client['project'], 'client_id' => $clientId, 'client_secret' => $client['secret'], 'redirect_uri' => $client['redirect']]);
237
238 //Iterate on each tokens
239 foreach($client['tokens'] as $tokenId => $token) {
240 //Set token
241 $googleClient->setAccessToken(
242 [
243 'access_token' => $tokenId,
244 'refresh_token' => $token['refresh'],
245 'expires_in' => $token['expire'],
246 'scope' => $token['scope'],
247 'token_type' => $token['type'],
248 'created' => $token['created']
249 ]
250 );
251
252 //With expired token
253 if ($exp = $googleClient->isAccessTokenExpired()) {
254 //Last chance to skip this run
255 continue;
256 }
257
258 //Get google calendar
259 $googleCalendar = new \Google\Service\Calendar($googleClient);
260
261 //Retrieve calendar
262 try {
263 $calendar = $googleCalendar->calendars->get($token['calendar']);
264 //Catch exception
265 } catch(\Google\Service\Exception $e) {
266 //Display exception
267 //TODO: handle codes here https://developers.google.com/calendar/api/guides/errors
268 echo 'Exception '.$e->getCode().':'.$e->getMessage().' in '.$e->getFile().' +'.$e->getLine()."\n";
269 echo $e->getTraceAsString()."\n";
270
271 //Return failure
272 return self::FAILURE;
273 }
274
275 //Init events
276 $events = [];
277
278 //Set filters
279 $filters = [
280 //XXX: show even deleted event to be able to update them
281 'showDeleted' => true,
282 //TODO: fetch events one day before and one day after to avoid triggering double insert duplicate key 409 errors :=) on google
283 'timeMin' => $period->getStartDate()->format(\DateTime::ISO8601),
284 'timeMax' => $period->getEndDate()->format(\DateTime::ISO8601)
285 /*, 'iCalUID' => 'airlibre/?????'*//*'orderBy' => 'startTime', */
286 ];
287
288 //Retrieve event collection
289 $googleEvents = $googleCalendar->events->listEvents($token['calendar'], $filters);
290
291 //Iterate until reached end
292 while (true) {
293 //Iterate on each event
294 foreach ($googleEvents->getItems() as $event) {
295 //Store event by id
296 if (preg_match('/^'.$token['prefix'].'([0-9]+)$/', $id = $event->getId(), $matches)) {
297 $events[$matches[1]] = $event;
298 //XXX: 3rd party events with id not matching prefix are skipped
299 #} else {
300 # echo 'Skipping '.$event->getId().':'.$event->getSummary()."\n";*/
301 }
302 }
303
304 //Get page token
305 $pageToken = $googleEvents->getNextPageToken();
306
307 //Handle next page
308 if ($pageToken) {
309 //Replace collection with next one
310 $googleEvents = $service->events->listEvents($token['calendar'], $filters+['pageToken' => $pageToken]);
311 } else {
312 break;
313 }
314 }
315
316 //Iterate on each session to sync
317 foreach($sessions as $sessionId => $session) {
318 //Init shared properties
319 //TODO: validate for constraints here ??? https://developers.google.com/calendar/api/guides/extended-properties
320 //TODO: drop shared as unused ???
321 $shared = [
322 'gps' => $session['l_latitude'].','.$session['l_longitude']
323 ];
324
325 //Init source
326 $source = [
327 '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']),
328 'url' => $this->router->generate('rapsys_air_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)
329 ];
330
331 //Init location
332 $description = 'Emplacement :'."\n".$this->translator->trans($session['l_description']);
333 $shared['location'] = $markdown->convert(strip_tags($session['l_description']));
334
335 //Add description
336 $description .= "\n\n".'Description :'."\n".strip_tags(preg_replace('!<a href="([^"]+)"(?: title="[^"]+")?'.'>([^<]+)</a>!', '\1', $markdown->convert(strip_tags($session['p_description']))));
337 $shared['description'] = $markdown->convert(strip_tags($session['p_description']));
338
339 //Add class when available
340 if (!empty($session['p_class'])) {
341 $shared['class'] = $session['p_class'];
342 $description .= "\n\n".'Classe :'."\n".$session['p_class'];
343 }
344
345 //Add contact when available
346 if (!empty($session['p_contact'])) {
347 $shared['contact'] = $session['p_contact'];
348 $description .= "\n\n".'Contact :'."\n".$session['p_contact'];
349 }
350
351 //Add donate when available
352 if (!empty($session['p_donate'])) {
353 $shared['donate'] = $session['p_donate'];
354 $description .= "\n\n".'Contribuer :'."\n".$session['p_donate'];
355 }
356
357 //Add link when available
358 if (!empty($session['p_link'])) {
359 $shared['link'] = $session['p_link'];
360 $description .= "\n\n".'Site :'."\n".$session['p_link'];
361 }
362
363 //Add profile when available
364 if (!empty($session['p_profile'])) {
365 $shared['profile'] = $session['p_profile'];
366 $description .= "\n\n".'Réseau social :'."\n".$session['p_profile'];
367 }
368
369 //Locked session
370 if (!empty($session['locked']) && $events[$sessionId]) {
371 //With events
372 if (!empty($event = $events[$sessionId])) {
373 try {
374 //Delete the event
375 $googleCalendar->events->delete($token['calendar'], $event->getId());
376 //Catch exception
377 } catch(\Google\Service\Exception $e) {
378 //Display exception
379 //TODO: handle codes here https://developers.google.com/calendar/api/guides/errors
380 echo 'Exception '.$e->getCode().':'.$e->getMessage().' in '.$e->getFile().' +'.$e->getLine()."\n";
381 echo $e->getTraceAsString()."\n";
382
383 //Return failure
384 return self::FAILURE;
385 }
386 }
387 //Without event
388 } elseif (empty($events[$sessionId])) {
389 //Init event
390 $event = new \Google\Service\Calendar\Event(
391 [
392 //TODO: replace 'airlibre' with $this->config['calendar']['prefix'] when possible with prefix validating [a-v0-9]{5,}
393 //XXX: see https://developers.google.com/calendar/api/v3/reference/events/insert#id
394 'id' => $token['prefix'].$sessionId,
395 #'summary' => $session['au_pseudonym'].' '.$this->translator->trans('at '.$session['l_title']),
396 'summary' => $source['title'],
397 #'description' => $markdown->convert(strip_tags($session['p_description'])),
398 'description' => $description,
399 'status' => empty($session['a_canceled'])?'confirmed':'cancelled',
400 'location' => implode(' ', [$session['l_address'], $session['l_zipcode'], $session['l_city']]),
401 'source' => $source,
402 'extendedProperties' => [
403 'shared' => $shared
404 ],
405 //TODO: colorId ?
406 //TODO: attendees[] ?
407 'start' => [
408 'dateTime' => $session['start']->format(\DateTime::ISO8601)
409 ],
410 'end' => [
411 'dateTime' => $session['stop']->format(\DateTime::ISO8601)
412 ]
413 ]
414 );
415
416 try {
417 //Insert the event
418 $googleCalendar->events->insert($token['calendar'], $event);
419 //Catch exception
420 } catch(\Google\Service\Exception $e) {
421 //Display exception
422 //TODO: handle codes here https://developers.google.com/calendar/api/guides/errors
423 echo 'Exception '.$e->getCode().':'.$e->getMessage().' in '.$e->getFile().' +'.$e->getLine()."\n";
424 echo $e->getTraceAsString()."\n";
425
426 //Return failure
427 return self::FAILURE;
428 }
429 // With event
430 } else {
431 //Set event
432 $event = $events[$sessionId];
433
434 //With updated event
435 if ($session['updated'] >= (new \DateTime($event->getUpdated()))) {
436 //Set summary
437 #$event->setSummary($session['au_pseudonym'].' '.$this->translator->trans('at '.$session['l_title']));
438 $event->setSummary($source['title']);
439
440 //Set description
441 $event->setDescription($description);
442
443 //Set status
444 $event->setStatus(empty($session['a_canceled'])?'confirmed':'cancelled');
445
446 //Set location
447 $event->setLocation(implode(' ', [$session['l_address'], $session['l_zipcode'], $session['l_city']]));
448
449 //Get source
450 $eventSource = $event->getSource();
451
452 //Update source title
453 $eventSource->setTitle($source['title']);
454
455 //Update source url
456 $eventSource->setUrl($source['url']);
457
458 //Set source
459 #$event->setSource($source);
460
461 //Get extended properties
462 $extendedProperties = $event->getExtendedProperties();
463
464 //Update shared
465 $extendedProperties->setShared($shared);
466
467 //TODO: colorId ?
468 //TODO: attendees[] ?
469
470 //Set start
471 $start = $event->getStart();
472
473 //Update start datetime
474 $start->setDateTime($session['start']->format(\DateTime::ISO8601));
475
476 //Set end
477 $end = $event->getEnd();
478
479 //Update stop datetime
480 $end->setDateTime($session['stop']->format(\DateTime::ISO8601));
481
482 try {
483 //Update the event
484 $updatedEvent = $googleCalendar->events->update($token['calendar'], $event->getId(), $event);
485 //Catch exception
486 } catch(\Google\Service\Exception $e) {
487 //Display exception
488 //TODO: handle codes here https://developers.google.com/calendar/api/guides/errors
489 echo 'Exception '.$e->getCode().':'.$e->getMessage().' in '.$e->getFile().' +'.$e->getLine()."\n";
490 echo $e->getTraceAsString()."\n";
491
492 //Return failure
493 return self::FAILURE;
494 }
495 }
496
497 //Drop from events array
498 unset($events[$sessionId]);
499 }
500 }
501
502 //Remaining events to drop
503 foreach($events as $eventId => $event) {
504 //Non canceled events
505 if ($event->getStatus() == 'confirmed') {
506 try {
507 //Delete the event
508 $googleCalendar->events->delete($token['calendar'], $event->getId());
509 //Catch exception
510 } catch(\Google\Service\Exception $e) {
511 //Display exception
512 //TODO: handle codes here https://developers.google.com/calendar/api/guides/errors
513 echo 'Exception '.$e->getCode().':'.$e->getMessage().' in '.$e->getFile().' +'.$e->getLine()."\n";
514 echo $e->getTraceAsString()."\n";
515
516 //Return failure
517 return self::FAILURE;
518 }
519 }
520 }
521 }
522 }
523
524 //Return success
525 return self::SUCCESS;
526 }
527
528 /**
529 * Return the bundle alias
530 *
531 * {@inheritdoc}
532 */
533 public function getAlias(): string {
534 return 'rapsys_air';
535 }
536 }