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