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.0.8
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        list($year, $month, $day) = explode('-', $date);
467
468        $dataDir = DOKU_INC . 'data/meta/';
469        if ($namespace) {
470            $dataDir .= str_replace(':', '/', $namespace) . '/';
471        }
472        $dataDir .= 'calendar/';
473
474        if (!is_dir($dataDir)) {
475            @mkdir($dataDir, 0755, true);
476        }
477
478        $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month);
479
480        // Load existing events
481        $events = [];
482        if (file_exists($eventFile)) {
483            $events = json_decode(file_get_contents($eventFile), true) ?: [];
484        }
485
486        // Check if this Google event already exists (by googleId)
487        if (isset($events[$date])) {
488            foreach ($events[$date] as $existing) {
489                if (isset($existing['googleId']) && $existing['googleId'] === $eventData['googleId']) {
490                    return ['success' => false, 'skipped' => true]; // Already imported
491                }
492            }
493        }
494
495        // Add event
496        if (!isset($events[$date])) {
497            $events[$date] = [];
498        }
499        $events[$date][] = $eventData;
500
501        // Save using file handler if available
502        if (class_exists('CalendarFileHandler')) {
503            CalendarFileHandler::writeJson($eventFile, $events);
504        } else {
505            file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT));
506        }
507
508        return ['success' => true];
509    }
510
511    /**
512     * Export events to Google Calendar
513     *
514     * @param string $namespace DokuWiki namespace to export from
515     * @param string $startDate Start date (Y-m-d)
516     * @param string $endDate End date (Y-m-d)
517     * @return array Result with exported count
518     */
519    public function exportEvents($namespace = '', $startDate = null, $endDate = null) {
520        if (!$this->isAuthenticated()) {
521            return ['success' => false, 'error' => 'Not authenticated'];
522        }
523
524        // Default date range
525        if (!$startDate) {
526            $startDate = date('Y-m-d');
527        }
528        if (!$endDate) {
529            $endDate = date('Y-m-d', strtotime('+12 months'));
530        }
531
532        $token = $this->getToken();
533        $calendarId = $this->config['calendar_id'] ?? 'primary';
534
535        // Find events in date range
536        $events = $this->getLocalEvents($namespace, $startDate, $endDate);
537
538        $exported = 0;
539        $skipped = 0;
540        $errors = [];
541
542        foreach ($events as $event) {
543            // Skip already-imported events (came from Google)
544            if (!empty($event['imported']) || !empty($event['googleId'])) {
545                $skipped++;
546                continue;
547            }
548
549            $result = $this->exportSingleEvent($event, $calendarId, $token['access_token']);
550            if ($result['success']) {
551                $exported++;
552            } else {
553                $errors[] = $result['error'];
554            }
555        }
556
557        if ($this->auditLogger) {
558            $this->auditLogger->log('google_export', [
559                'namespace' => $namespace,
560                'exported' => $exported,
561                'skipped' => $skipped,
562                'date_range' => "$startDate to $endDate"
563            ]);
564        }
565
566        return [
567            'success' => true,
568            'exported' => $exported,
569            'skipped' => $skipped,
570            'errors' => $errors
571        ];
572    }
573
574    /**
575     * Export a single event to Google
576     */
577    private function exportSingleEvent($event, $calendarId, $accessToken) {
578        $date = $event['date'];
579        $endDate = $event['endDate'] ?? $date;
580
581        // Build Google event
582        if (empty($event['time'])) {
583            // All-day event
584            $gEvent = [
585                'summary' => $event['title'],
586                'description' => $event['description'] ?? '',
587                'start' => ['date' => $date],
588                'end' => ['date' => date('Y-m-d', strtotime($endDate . ' +1 day'))] // Google expects exclusive end
589            ];
590        } else {
591            // Timed event
592            $startTime = $date . 'T' . $event['time'] . ':00';
593            $endTime = ($endDate ?: $date) . 'T' . ($event['endTime'] ?: $event['time']) . ':00';
594
595            $gEvent = [
596                'summary' => $event['title'],
597                'description' => $event['description'] ?? '',
598                'start' => ['dateTime' => $startTime, 'timeZone' => date_default_timezone_get()],
599                'end' => ['dateTime' => $endTime, 'timeZone' => date_default_timezone_get()]
600            ];
601        }
602
603        // Set color if available
604        $colorId = $this->colorToGoogle($event['color'] ?? null);
605        if ($colorId) {
606            $gEvent['colorId'] = $colorId;
607        }
608
609        // Create event via API
610        $url = self::CALENDAR_API . '/calendars/' . urlencode($calendarId) . '/events';
611        $response = $this->httpPost($url, $gEvent, $accessToken, true);
612
613        if (!$response || isset($response['error'])) {
614            return [
615                'success' => false,
616                'error' => ($event['title'] ?? 'Event') . ': ' . ($response['error']['message'] ?? 'Failed to create')
617            ];
618        }
619
620        return ['success' => true, 'googleId' => $response['id']];
621    }
622
623    /**
624     * Get local calendar events
625     */
626    private function getLocalEvents($namespace, $startDate, $endDate) {
627        $events = [];
628
629        $dataDir = DOKU_INC . 'data/meta/';
630        if ($namespace) {
631            $dataDir .= str_replace(':', '/', $namespace) . '/';
632        }
633        $dataDir .= 'calendar/';
634
635        if (!is_dir($dataDir)) {
636            return $events;
637        }
638
639        // Parse date range
640        $startObj = new DateTime($startDate);
641        $endObj = new DateTime($endDate);
642
643        // Iterate through month files
644        $current = clone $startObj;
645        $current->modify('first day of this month');
646
647        while ($current <= $endObj) {
648            $file = $dataDir . $current->format('Y-m') . '.json';
649
650            if (file_exists($file)) {
651                $data = json_decode(file_get_contents($file), true) ?: [];
652
653                foreach ($data as $date => $dayEvents) {
654                    if ($date >= $startDate && $date <= $endDate) {
655                        foreach ($dayEvents as $event) {
656                            $event['date'] = $date;
657                            $events[] = $event;
658                        }
659                    }
660                }
661            }
662
663            $current->modify('+1 month');
664        }
665
666        return $events;
667    }
668
669    /**
670     * Convert Google color ID to hex
671     */
672    private function colorFromGoogle($colorId) {
673        $colors = [
674            '1' => '#7986cb',  // Lavender
675            '2' => '#33b679',  // Sage
676            '3' => '#8e24aa',  // Grape
677            '4' => '#e67c73',  // Flamingo
678            '5' => '#f6c026',  // Banana
679            '6' => '#f5511d',  // Tangerine
680            '7' => '#039be5',  // Peacock
681            '8' => '#616161',  // Graphite
682            '9' => '#3f51b5',  // Blueberry
683            '10' => '#0b8043', // Basil
684            '11' => '#d60000', // Tomato
685        ];
686
687        return $colors[$colorId] ?? '#3498db';
688    }
689
690    /**
691     * Convert hex color to Google color ID
692     */
693    private function colorToGoogle($hex) {
694        if (!$hex) return null;
695
696        $hex = strtolower($hex);
697
698        // Map common colors to Google IDs
699        $map = [
700            '#7986cb' => '1', '#33b679' => '2', '#8e24aa' => '3',
701            '#e67c73' => '4', '#f6c026' => '5', '#f5511d' => '6',
702            '#039be5' => '7', '#616161' => '8', '#3f51b5' => '9',
703            '#0b8043' => '10', '#d60000' => '11',
704            // Common defaults
705            '#3498db' => '7', // Blue -> Peacock
706            '#e74c3c' => '11', // Red -> Tomato
707            '#2ecc71' => '2', // Green -> Sage
708            '#9b59b6' => '3', // Purple -> Grape
709            '#f39c12' => '5', // Orange -> Banana
710        ];
711
712        return $map[$hex] ?? null;
713    }
714
715    /**
716     * HTTP GET request
717     */
718    private function httpGet($url, $accessToken = null) {
719        $headers = ['Accept: application/json'];
720
721        if ($accessToken) {
722            $headers[] = 'Authorization: Bearer ' . $accessToken;
723        }
724
725        $ch = curl_init();
726        curl_setopt_array($ch, [
727            CURLOPT_URL => $url,
728            CURLOPT_RETURNTRANSFER => true,
729            CURLOPT_HTTPHEADER => $headers,
730            CURLOPT_TIMEOUT => 30
731        ]);
732
733        $response = curl_exec($ch);
734        curl_close($ch);
735
736        return json_decode($response, true);
737    }
738
739    /**
740     * HTTP POST request
741     */
742    private function httpPost($url, $data, $accessToken = null, $json = false) {
743        $headers = ['Accept: application/json'];
744
745        if ($accessToken) {
746            $headers[] = 'Authorization: Bearer ' . $accessToken;
747        }
748
749        if ($json) {
750            $headers[] = 'Content-Type: application/json';
751            $postData = json_encode($data);
752        } else {
753            $headers[] = 'Content-Type: application/x-www-form-urlencoded';
754            $postData = http_build_query($data);
755        }
756
757        $ch = curl_init();
758        curl_setopt_array($ch, [
759            CURLOPT_URL => $url,
760            CURLOPT_RETURNTRANSFER => true,
761            CURLOPT_POST => true,
762            CURLOPT_POSTFIELDS => $postData,
763            CURLOPT_HTTPHEADER => $headers,
764            CURLOPT_TIMEOUT => 30
765        ]);
766
767        $response = curl_exec($ch);
768        curl_close($ch);
769
770        return json_decode($response, true);
771    }
772
773    /**
774     * Get sync status information
775     */
776    public function getStatus() {
777        return [
778            'configured' => $this->isConfigured(),
779            'authenticated' => $this->isAuthenticated(),
780            'calendar_id' => $this->config['calendar_id'] ?? 'primary',
781            'has_client_id' => !empty($this->config['client_id']),
782            'config_date' => $this->config['updated'] ?? null
783        ];
784    }
785
786    /**
787     * Get the configured calendar ID
788     */
789    public function getCalendarId() {
790        return $this->config['calendar_id'] ?? 'primary';
791    }
792
793    /**
794     * Set the calendar ID to sync with
795     */
796    public function setCalendarId($calendarId) {
797        $this->config['calendar_id'] = $calendarId;
798        file_put_contents($this->configFile, json_encode($this->config, JSON_PRETTY_PRINT));
799        return true;
800    }
801}
802