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