xref: /plugin/calendar/classes/GoogleCalendarSync.php (revision 2866e8271e4daef3b32eacb3a9082d02159b592b)
1<?php
2/**
3 * Calendar Plugin - Google Calendar Sync
4 *
5 * Provides two-way synchronization with Google Calendar using OAuth 2.0.
6 *
7 * Setup:
8 * 1. Create a project in Google Cloud Console
9 * 2. Enable Google Calendar API
10 * 3. Create OAuth 2.0 credentials (Web application)
11 * 4. Add redirect URI: https://yoursite.com/lib/exe/ajax.php
12 * 5. Enter Client ID and Client Secret in plugin admin
13 *
14 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
15 * @author  DokuWiki Community
16 * @version 7.2.6
17 */
18
19if (!defined('DOKU_INC')) die();
20
21class GoogleCalendarSync {
22
23    /** @var string Google OAuth endpoints */
24    const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
25    const TOKEN_URL = 'https://oauth2.googleapis.com/token';
26    const CALENDAR_API = 'https://www.googleapis.com/calendar/v3';
27
28    /** @var string Required OAuth scopes */
29    const SCOPES = 'https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/calendar.events';
30
31    /** @var string Path to config and token storage */
32    private $configDir;
33    private $configFile;
34    private $tokenFile;
35
36    /** @var array Configuration */
37    private $config = [];
38
39    /** @var CalendarAuditLogger */
40    private $auditLogger;
41
42    /**
43     * Constructor
44     */
45    public function __construct() {
46        global $conf;
47        $this->configDir = $conf['metadir'] . '/calendar/';
48        $this->configFile = $this->configDir . 'google_config.json';
49        $this->tokenFile = $this->configDir . 'google_token.json';
50
51        if (!is_dir($this->configDir)) {
52            @mkdir($this->configDir, 0775, true);
53        }
54
55        $this->loadConfig();
56
57        // Load audit logger if available
58        if (class_exists('CalendarAuditLogger')) {
59            $this->auditLogger = new CalendarAuditLogger();
60        }
61    }
62
63    /**
64     * Load configuration from file
65     */
66    private function loadConfig() {
67        if (file_exists($this->configFile)) {
68            $data = file_get_contents($this->configFile);
69            $this->config = json_decode($data, true) ?: [];
70        }
71    }
72
73    /**
74     * Save configuration to file
75     */
76    public function saveConfig($clientId, $clientSecret, $calendarId = 'primary') {
77        $this->config = [
78            'client_id' => $clientId,
79            'client_secret' => $clientSecret,
80            'calendar_id' => $calendarId,
81            'updated' => date('Y-m-d H:i:s')
82        ];
83
84        file_put_contents($this->configFile, json_encode($this->config, JSON_PRETTY_PRINT));
85
86        // Secure the file
87        @chmod($this->configFile, 0600);
88
89        return true;
90    }
91
92    /**
93     * Check if Google sync is configured
94     */
95    public function isConfigured() {
96        return !empty($this->config['client_id']) && !empty($this->config['client_secret']);
97    }
98
99    /**
100     * Check if we have a valid access token
101     */
102    public function isAuthenticated() {
103        if (!file_exists($this->tokenFile)) {
104            return false;
105        }
106
107        $token = $this->getToken();
108        if (!$token || empty($token['access_token'])) {
109            return false;
110        }
111
112        // Check if token is expired
113        if (isset($token['expires_at']) && time() >= $token['expires_at']) {
114            // Try to refresh
115            if (!empty($token['refresh_token'])) {
116                return $this->refreshToken($token['refresh_token']);
117            }
118            return false;
119        }
120
121        return true;
122    }
123
124    /**
125     * Get the OAuth authorization URL
126     */
127    public function getAuthUrl($redirectUri) {
128        if (!$this->isConfigured()) {
129            return null;
130        }
131
132        $state = bin2hex(random_bytes(16));
133        $this->saveState($state);
134
135        $params = [
136            'client_id' => $this->config['client_id'],
137            'redirect_uri' => $redirectUri,
138            'response_type' => 'code',
139            'scope' => self::SCOPES,
140            'access_type' => 'offline',
141            'prompt' => 'consent',
142            'state' => $state
143        ];
144
145        return self::AUTH_URL . '?' . http_build_query($params);
146    }
147
148    /**
149     * Save OAuth state for CSRF protection
150     */
151    private function saveState($state) {
152        $stateFile = $this->configDir . 'google_state.json';
153        file_put_contents($stateFile, json_encode([
154            'state' => $state,
155            'created' => time()
156        ]));
157    }
158
159    /**
160     * Verify OAuth state
161     */
162    public function verifyState($state) {
163        $stateFile = $this->configDir . 'google_state.json';
164        if (!file_exists($stateFile)) {
165            return false;
166        }
167
168        $data = json_decode(file_get_contents($stateFile), true);
169        @unlink($stateFile); // One-time use
170
171        // Check state matches and is not too old (10 minutes)
172        if ($data['state'] === $state && (time() - $data['created']) < 600) {
173            return true;
174        }
175
176        return false;
177    }
178
179    /**
180     * Exchange authorization code for tokens
181     */
182    public function handleCallback($code, $redirectUri) {
183        if (!$this->isConfigured()) {
184            return ['success' => false, 'error' => 'Google sync not configured'];
185        }
186
187        $params = [
188            'client_id' => $this->config['client_id'],
189            'client_secret' => $this->config['client_secret'],
190            'code' => $code,
191            'grant_type' => 'authorization_code',
192            'redirect_uri' => $redirectUri
193        ];
194
195        $response = $this->httpPost(self::TOKEN_URL, $params);
196
197        if (!$response || isset($response['error'])) {
198            return [
199                'success' => false,
200                'error' => $response['error_description'] ?? $response['error'] ?? 'Token exchange failed'
201            ];
202        }
203
204        // Save token with expiry time
205        $token = [
206            'access_token' => $response['access_token'],
207            'refresh_token' => $response['refresh_token'] ?? null,
208            'token_type' => $response['token_type'] ?? 'Bearer',
209            'expires_at' => time() + ($response['expires_in'] ?? 3600),
210            'created' => date('Y-m-d H:i:s')
211        ];
212
213        $this->saveToken($token);
214
215        if ($this->auditLogger) {
216            $this->auditLogger->log('google_auth', ['action' => 'connected']);
217        }
218
219        return ['success' => true];
220    }
221
222    /**
223     * Refresh the access token
224     */
225    private function refreshToken($refreshToken) {
226        $params = [
227            'client_id' => $this->config['client_id'],
228            'client_secret' => $this->config['client_secret'],
229            'refresh_token' => $refreshToken,
230            'grant_type' => 'refresh_token'
231        ];
232
233        $response = $this->httpPost(self::TOKEN_URL, $params);
234
235        if (!$response || isset($response['error'])) {
236            return false;
237        }
238
239        // Update token
240        $token = $this->getToken();
241        $token['access_token'] = $response['access_token'];
242        $token['expires_at'] = time() + ($response['expires_in'] ?? 3600);
243
244        // Preserve refresh token if not returned
245        if (isset($response['refresh_token'])) {
246            $token['refresh_token'] = $response['refresh_token'];
247        }
248
249        $this->saveToken($token);
250
251        return true;
252    }
253
254    /**
255     * Save token to file
256     */
257    private function saveToken($token) {
258        file_put_contents($this->tokenFile, json_encode($token, JSON_PRETTY_PRINT));
259        @chmod($this->tokenFile, 0600);
260    }
261
262    /**
263     * Get current token
264     */
265    private function getToken() {
266        if (!file_exists($this->tokenFile)) {
267            return null;
268        }
269        return json_decode(file_get_contents($this->tokenFile), true);
270    }
271
272    /**
273     * Disconnect from Google Calendar
274     */
275    public function disconnect() {
276        if (file_exists($this->tokenFile)) {
277            @unlink($this->tokenFile);
278        }
279
280        if ($this->auditLogger) {
281            $this->auditLogger->log('google_auth', ['action' => 'disconnected']);
282        }
283
284        return true;
285    }
286
287    /**
288     * Get list of user's calendars
289     */
290    public function getCalendars() {
291        if (!$this->isAuthenticated()) {
292            return ['success' => false, 'error' => 'Not authenticated'];
293        }
294
295        $token = $this->getToken();
296        $url = self::CALENDAR_API . '/users/me/calendarList';
297
298        $response = $this->httpGet($url, $token['access_token']);
299
300        if (!$response || isset($response['error'])) {
301            return [
302                'success' => false,
303                'error' => $response['error']['message'] ?? 'Failed to get calendars'
304            ];
305        }
306
307        $calendars = [];
308        foreach ($response['items'] ?? [] as $cal) {
309            $calendars[] = [
310                'id' => $cal['id'],
311                'summary' => $cal['summary'],
312                'primary' => $cal['primary'] ?? false,
313                'accessRole' => $cal['accessRole']
314            ];
315        }
316
317        return ['success' => true, 'calendars' => $calendars];
318    }
319
320    /**
321     * Import events from Google Calendar
322     *
323     * @param string $namespace DokuWiki namespace to import into
324     * @param string $startDate Start date (Y-m-d)
325     * @param string $endDate End date (Y-m-d)
326     * @return array Result with imported count
327     */
328    public function importEvents($namespace = '', $startDate = null, $endDate = null) {
329        if (!$this->isAuthenticated()) {
330            return ['success' => false, 'error' => 'Not authenticated'];
331        }
332
333        // Default date range: 3 months past to 12 months future
334        if (!$startDate) {
335            $startDate = date('Y-m-d', strtotime('-3 months'));
336        }
337        if (!$endDate) {
338            $endDate = date('Y-m-d', strtotime('+12 months'));
339        }
340
341        $token = $this->getToken();
342        $calendarId = $this->config['calendar_id'] ?? 'primary';
343
344        // Build API URL
345        $url = self::CALENDAR_API . '/calendars/' . urlencode($calendarId) . '/events';
346        $params = [
347            'timeMin' => $startDate . 'T00:00:00Z',
348            'timeMax' => $endDate . 'T23:59:59Z',
349            'singleEvents' => 'true',  // Expand recurring events
350            'orderBy' => 'startTime',
351            'maxResults' => 2500
352        ];
353
354        $response = $this->httpGet($url . '?' . http_build_query($params), $token['access_token']);
355
356        if (!$response || isset($response['error'])) {
357            return [
358                'success' => false,
359                'error' => $response['error']['message'] ?? 'Failed to fetch events'
360            ];
361        }
362
363        // Process and save events
364        $imported = 0;
365        $skipped = 0;
366        $errors = [];
367
368        foreach ($response['items'] ?? [] as $gEvent) {
369            $result = $this->importSingleEvent($gEvent, $namespace);
370            if ($result['success']) {
371                $imported++;
372            } elseif ($result['skipped']) {
373                $skipped++;
374            } else {
375                $errors[] = $result['error'];
376            }
377        }
378
379        if ($this->auditLogger) {
380            $this->auditLogger->log('google_import', [
381                'namespace' => $namespace,
382                'imported' => $imported,
383                'skipped' => $skipped,
384                'date_range' => "$startDate to $endDate"
385            ]);
386        }
387
388        return [
389            'success' => true,
390            'imported' => $imported,
391            'skipped' => $skipped,
392            'errors' => $errors
393        ];
394    }
395
396    /**
397     * Import a single Google event
398     */
399    private function importSingleEvent($gEvent, $namespace) {
400        // Skip cancelled events
401        if (($gEvent['status'] ?? '') === 'cancelled') {
402            return ['success' => false, 'skipped' => true];
403        }
404
405        // Parse date/time
406        $startDateTime = $gEvent['start']['dateTime'] ?? $gEvent['start']['date'] ?? null;
407        $endDateTime = $gEvent['end']['dateTime'] ?? $gEvent['end']['date'] ?? null;
408
409        if (!$startDateTime) {
410            return ['success' => false, 'skipped' => true, 'error' => 'No start date'];
411        }
412
413        // Determine if all-day event
414        $isAllDay = isset($gEvent['start']['date']) && !isset($gEvent['start']['dateTime']);
415
416        // Parse dates
417        if ($isAllDay) {
418            $date = $gEvent['start']['date'];
419            $endDate = $gEvent['end']['date'];
420            // Google all-day events end on the next day
421            $endDate = date('Y-m-d', strtotime($endDate . ' -1 day'));
422            $time = '';
423            $endTime = '';
424        } else {
425            $startObj = new DateTime($startDateTime);
426            $endObj = new DateTime($endDateTime);
427
428            $date = $startObj->format('Y-m-d');
429            $endDate = $endObj->format('Y-m-d');
430            $time = $startObj->format('H:i');
431            $endTime = $endObj->format('H:i');
432
433            // If same day, don't set endDate
434            if ($date === $endDate) {
435                $endDate = '';
436            }
437        }
438
439        // Build event data
440        $eventId = 'g_' . substr(md5($gEvent['id']), 0, 8) . '_' . time();
441
442        $eventData = [
443            'id' => $eventId,
444            'title' => $gEvent['summary'] ?? 'Untitled',
445            'time' => $time,
446            'endTime' => $endTime,
447            'description' => $gEvent['description'] ?? '',
448            'color' => $this->colorFromGoogle($gEvent['colorId'] ?? null),
449            'isTask' => false,
450            'completed' => false,
451            'endDate' => $endDate,
452            'namespace' => $namespace,
453            'googleId' => $gEvent['id'],
454            'created' => date('Y-m-d H:i:s'),
455            'imported' => true
456        ];
457
458        // Save to calendar file
459        return $this->saveImportedEvent($namespace, $date, $eventData);
460    }
461
462    /**
463     * Save an imported event to the calendar JSON file
464     */
465    private function saveImportedEvent($namespace, $date, $eventData) {
466        global $conf;
467        list($year, $month, $day) = explode('-', $date);
468
469        $dataDir = $conf['metadir'] . '/';
470        if ($namespace) {
471            $dataDir .= str_replace(':', '/', $namespace) . '/';
472        }
473        $dataDir .= 'calendar/';
474
475        if (!is_dir($dataDir)) {
476            @mkdir($dataDir, 0755, true);
477        }
478
479        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
480
481        // Load existing events
482        $events = [];
483        if (file_exists($eventFile)) {
484            $events = json_decode(file_get_contents($eventFile), true) ?: [];
485        }
486
487        // Check if this Google event already exists (by googleId)
488        if (isset($events[$date])) {
489            foreach ($events[$date] as $existing) {
490                if (isset($existing['googleId']) && $existing['googleId'] === $eventData['googleId']) {
491                    return ['success' => false, 'skipped' => true]; // Already imported
492                }
493            }
494        }
495
496        // Add event
497        if (!isset($events[$date])) {
498            $events[$date] = [];
499        }
500        $events[$date][] = $eventData;
501
502        // Save using file handler if available
503        if (class_exists('CalendarFileHandler')) {
504            CalendarFileHandler::writeJson($eventFile, $events);
505        } else {
506            file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT));
507        }
508
509        return ['success' => true];
510    }
511
512    /**
513     * Export events to Google Calendar
514     *
515     * @param string $namespace DokuWiki namespace to export from
516     * @param string $startDate Start date (Y-m-d)
517     * @param string $endDate End date (Y-m-d)
518     * @return array Result with exported count
519     */
520    public function exportEvents($namespace = '', $startDate = null, $endDate = null) {
521        if (!$this->isAuthenticated()) {
522            return ['success' => false, 'error' => 'Not authenticated'];
523        }
524
525        // Default date range
526        if (!$startDate) {
527            $startDate = date('Y-m-d');
528        }
529        if (!$endDate) {
530            $endDate = date('Y-m-d', strtotime('+12 months'));
531        }
532
533        $token = $this->getToken();
534        $calendarId = $this->config['calendar_id'] ?? 'primary';
535
536        // Find events in date range
537        $events = $this->getLocalEvents($namespace, $startDate, $endDate);
538
539        $exported = 0;
540        $skipped = 0;
541        $errors = [];
542
543        foreach ($events as $event) {
544            // Skip already-imported events (came from Google)
545            if (!empty($event['imported']) || !empty($event['googleId'])) {
546                $skipped++;
547                continue;
548            }
549
550            $result = $this->exportSingleEvent($event, $calendarId, $token['access_token']);
551            if ($result['success']) {
552                $exported++;
553            } else {
554                $errors[] = $result['error'];
555            }
556        }
557
558        if ($this->auditLogger) {
559            $this->auditLogger->log('google_export', [
560                'namespace' => $namespace,
561                'exported' => $exported,
562                'skipped' => $skipped,
563                'date_range' => "$startDate to $endDate"
564            ]);
565        }
566
567        return [
568            'success' => true,
569            'exported' => $exported,
570            'skipped' => $skipped,
571            'errors' => $errors
572        ];
573    }
574
575    /**
576     * Export a single event to Google
577     */
578    private function exportSingleEvent($event, $calendarId, $accessToken) {
579        $date = $event['date'];
580        $endDate = $event['endDate'] ?? $date;
581
582        // Build Google event
583        if (empty($event['time'])) {
584            // All-day event
585            $gEvent = [
586                'summary' => $event['title'],
587                'description' => $event['description'] ?? '',
588                'start' => ['date' => $date],
589                'end' => ['date' => date('Y-m-d', strtotime($endDate . ' +1 day'))] // Google expects exclusive end
590            ];
591        } else {
592            // Timed event
593            $startTime = $date . 'T' . $event['time'] . ':00';
594            $endTime = ($endDate ?: $date) . 'T' . ($event['endTime'] ?: $event['time']) . ':00';
595
596            $gEvent = [
597                'summary' => $event['title'],
598                'description' => $event['description'] ?? '',
599                'start' => ['dateTime' => $startTime, 'timeZone' => date_default_timezone_get()],
600                'end' => ['dateTime' => $endTime, 'timeZone' => date_default_timezone_get()]
601            ];
602        }
603
604        // Set color if available
605        $colorId = $this->colorToGoogle($event['color'] ?? null);
606        if ($colorId) {
607            $gEvent['colorId'] = $colorId;
608        }
609
610        // Create event via API
611        $url = self::CALENDAR_API . '/calendars/' . urlencode($calendarId) . '/events';
612        $response = $this->httpPost($url, $gEvent, $accessToken, true);
613
614        if (!$response || isset($response['error'])) {
615            return [
616                'success' => false,
617                'error' => ($event['title'] ?? 'Event') . ': ' . ($response['error']['message'] ?? 'Failed to create')
618            ];
619        }
620
621        return ['success' => true, 'googleId' => $response['id']];
622    }
623
624    /**
625     * Get local calendar events
626     */
627    private function getLocalEvents($namespace, $startDate, $endDate) {
628        global $conf;
629        $events = [];
630
631        $dataDir = $conf['metadir'] . '/';
632        if ($namespace) {
633            $dataDir .= str_replace(':', '/', $namespace) . '/';
634        }
635        $dataDir .= 'calendar/';
636
637        if (!is_dir($dataDir)) {
638            return $events;
639        }
640
641        // Parse date range
642        $startObj = new DateTime($startDate);
643        $endObj = new DateTime($endDate);
644
645        // Iterate through month files
646        $current = clone $startObj;
647        $current->modify('first day of this month');
648
649        while ($current <= $endObj) {
650            $file = $dataDir . $current->format('Y-m') . '.json';
651
652            if (file_exists($file)) {
653                $data = json_decode(file_get_contents($file), true) ?: [];
654
655                foreach ($data as $date => $dayEvents) {
656                    if ($date >= $startDate && $date <= $endDate) {
657                        foreach ($dayEvents as $event) {
658                            $event['date'] = $date;
659                            $events[] = $event;
660                        }
661                    }
662                }
663            }
664
665            $current->modify('+1 month');
666        }
667
668        return $events;
669    }
670
671    /**
672     * Convert Google color ID to hex
673     */
674    private function colorFromGoogle($colorId) {
675        $colors = [
676            '1' => '#7986cb',  // Lavender
677            '2' => '#33b679',  // Sage
678            '3' => '#8e24aa',  // Grape
679            '4' => '#e67c73',  // Flamingo
680            '5' => '#f6c026',  // Banana
681            '6' => '#f5511d',  // Tangerine
682            '7' => '#039be5',  // Peacock
683            '8' => '#616161',  // Graphite
684            '9' => '#3f51b5',  // Blueberry
685            '10' => '#0b8043', // Basil
686            '11' => '#d60000', // Tomato
687        ];
688
689        return $colors[$colorId] ?? '#3498db';
690    }
691
692    /**
693     * Convert hex color to Google color ID
694     */
695    private function colorToGoogle($hex) {
696        if (!$hex) return null;
697
698        $hex = strtolower($hex);
699
700        // Map common colors to Google IDs
701        $map = [
702            '#7986cb' => '1', '#33b679' => '2', '#8e24aa' => '3',
703            '#e67c73' => '4', '#f6c026' => '5', '#f5511d' => '6',
704            '#039be5' => '7', '#616161' => '8', '#3f51b5' => '9',
705            '#0b8043' => '10', '#d60000' => '11',
706            // Common defaults
707            '#3498db' => '7', // Blue -> Peacock
708            '#e74c3c' => '11', // Red -> Tomato
709            '#2ecc71' => '2', // Green -> Sage
710            '#9b59b6' => '3', // Purple -> Grape
711            '#f39c12' => '5', // Orange -> Banana
712        ];
713
714        return $map[$hex] ?? null;
715    }
716
717    /**
718     * HTTP GET request
719     */
720    private function httpGet($url, $accessToken = null) {
721        $headers = ['Accept: application/json'];
722
723        if ($accessToken) {
724            $headers[] = 'Authorization: Bearer ' . $accessToken;
725        }
726
727        $ch = curl_init();
728        curl_setopt_array($ch, [
729            CURLOPT_URL => $url,
730            CURLOPT_RETURNTRANSFER => true,
731            CURLOPT_HTTPHEADER => $headers,
732            CURLOPT_TIMEOUT => 30
733        ]);
734
735        $response = curl_exec($ch);
736        curl_close($ch);
737
738        return json_decode($response, true);
739    }
740
741    /**
742     * HTTP POST request
743     */
744    private function httpPost($url, $data, $accessToken = null, $json = false) {
745        $headers = ['Accept: application/json'];
746
747        if ($accessToken) {
748            $headers[] = 'Authorization: Bearer ' . $accessToken;
749        }
750
751        if ($json) {
752            $headers[] = 'Content-Type: application/json';
753            $postData = json_encode($data);
754        } else {
755            $headers[] = 'Content-Type: application/x-www-form-urlencoded';
756            $postData = http_build_query($data);
757        }
758
759        $ch = curl_init();
760        curl_setopt_array($ch, [
761            CURLOPT_URL => $url,
762            CURLOPT_RETURNTRANSFER => true,
763            CURLOPT_POST => true,
764            CURLOPT_POSTFIELDS => $postData,
765            CURLOPT_HTTPHEADER => $headers,
766            CURLOPT_TIMEOUT => 30
767        ]);
768
769        $response = curl_exec($ch);
770        curl_close($ch);
771
772        return json_decode($response, true);
773    }
774
775    /**
776     * Get sync status information
777     */
778    public function getStatus() {
779        return [
780            'configured' => $this->isConfigured(),
781            'authenticated' => $this->isAuthenticated(),
782            'calendar_id' => $this->config['calendar_id'] ?? 'primary',
783            'has_client_id' => !empty($this->config['client_id']),
784            'config_date' => $this->config['updated'] ?? null
785        ];
786    }
787
788    /**
789     * Get the configured calendar ID
790     */
791    public function getCalendarId() {
792        return $this->config['calendar_id'] ?? 'primary';
793    }
794
795    /**
796     * Set the calendar ID to sync with
797     */
798    public function setCalendarId($calendarId) {
799        $this->config['calendar_id'] = $calendarId;
800        file_put_contents($this->configFile, json_encode($this->config, JSON_PRETTY_PRINT));
801        return true;
802    }
803}
804