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