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