1815440faSAtari911<?php 2815440faSAtari911/** 3815440faSAtari911 * Calendar Plugin - Google Calendar Sync 4815440faSAtari911 * 5815440faSAtari911 * Provides two-way synchronization with Google Calendar using OAuth 2.0. 6815440faSAtari911 * 7815440faSAtari911 * Setup: 8815440faSAtari911 * 1. Create a project in Google Cloud Console 9815440faSAtari911 * 2. Enable Google Calendar API 10815440faSAtari911 * 3. Create OAuth 2.0 credentials (Web application) 11815440faSAtari911 * 4. Add redirect URI: https://yoursite.com/lib/exe/ajax.php 12815440faSAtari911 * 5. Enter Client ID and Client Secret in plugin admin 13815440faSAtari911 * 14815440faSAtari911 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html 15815440faSAtari911 * @author DokuWiki Community 16*2866e827SAtari911 * @version 7.2.6 17815440faSAtari911 */ 18815440faSAtari911 19815440faSAtari911if (!defined('DOKU_INC')) die(); 20815440faSAtari911 21815440faSAtari911class GoogleCalendarSync { 22815440faSAtari911 23815440faSAtari911 /** @var string Google OAuth endpoints */ 24815440faSAtari911 const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'; 25815440faSAtari911 const TOKEN_URL = 'https://oauth2.googleapis.com/token'; 26815440faSAtari911 const CALENDAR_API = 'https://www.googleapis.com/calendar/v3'; 27815440faSAtari911 28815440faSAtari911 /** @var string Required OAuth scopes */ 29815440faSAtari911 const SCOPES = 'https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/calendar.events'; 30815440faSAtari911 31815440faSAtari911 /** @var string Path to config and token storage */ 32815440faSAtari911 private $configDir; 33815440faSAtari911 private $configFile; 34815440faSAtari911 private $tokenFile; 35815440faSAtari911 36815440faSAtari911 /** @var array Configuration */ 37815440faSAtari911 private $config = []; 38815440faSAtari911 39815440faSAtari911 /** @var CalendarAuditLogger */ 40815440faSAtari911 private $auditLogger; 41815440faSAtari911 42815440faSAtari911 /** 43815440faSAtari911 * Constructor 44815440faSAtari911 */ 45815440faSAtari911 public function __construct() { 46815440faSAtari911 global $conf; 47815440faSAtari911 $this->configDir = $conf['metadir'] . '/calendar/'; 48815440faSAtari911 $this->configFile = $this->configDir . 'google_config.json'; 49815440faSAtari911 $this->tokenFile = $this->configDir . 'google_token.json'; 50815440faSAtari911 51815440faSAtari911 if (!is_dir($this->configDir)) { 52815440faSAtari911 @mkdir($this->configDir, 0775, true); 53815440faSAtari911 } 54815440faSAtari911 55815440faSAtari911 $this->loadConfig(); 56815440faSAtari911 57815440faSAtari911 // Load audit logger if available 58815440faSAtari911 if (class_exists('CalendarAuditLogger')) { 59815440faSAtari911 $this->auditLogger = new CalendarAuditLogger(); 60815440faSAtari911 } 61815440faSAtari911 } 62815440faSAtari911 63815440faSAtari911 /** 64815440faSAtari911 * Load configuration from file 65815440faSAtari911 */ 66815440faSAtari911 private function loadConfig() { 67815440faSAtari911 if (file_exists($this->configFile)) { 68815440faSAtari911 $data = file_get_contents($this->configFile); 69815440faSAtari911 $this->config = json_decode($data, true) ?: []; 70815440faSAtari911 } 71815440faSAtari911 } 72815440faSAtari911 73815440faSAtari911 /** 74815440faSAtari911 * Save configuration to file 75815440faSAtari911 */ 76815440faSAtari911 public function saveConfig($clientId, $clientSecret, $calendarId = 'primary') { 77815440faSAtari911 $this->config = [ 78815440faSAtari911 'client_id' => $clientId, 79815440faSAtari911 'client_secret' => $clientSecret, 80815440faSAtari911 'calendar_id' => $calendarId, 81815440faSAtari911 'updated' => date('Y-m-d H:i:s') 82815440faSAtari911 ]; 83815440faSAtari911 84815440faSAtari911 file_put_contents($this->configFile, json_encode($this->config, JSON_PRETTY_PRINT)); 85815440faSAtari911 86815440faSAtari911 // Secure the file 87815440faSAtari911 @chmod($this->configFile, 0600); 88815440faSAtari911 89815440faSAtari911 return true; 90815440faSAtari911 } 91815440faSAtari911 92815440faSAtari911 /** 93815440faSAtari911 * Check if Google sync is configured 94815440faSAtari911 */ 95815440faSAtari911 public function isConfigured() { 96815440faSAtari911 return !empty($this->config['client_id']) && !empty($this->config['client_secret']); 97815440faSAtari911 } 98815440faSAtari911 99815440faSAtari911 /** 100815440faSAtari911 * Check if we have a valid access token 101815440faSAtari911 */ 102815440faSAtari911 public function isAuthenticated() { 103815440faSAtari911 if (!file_exists($this->tokenFile)) { 104815440faSAtari911 return false; 105815440faSAtari911 } 106815440faSAtari911 107815440faSAtari911 $token = $this->getToken(); 108815440faSAtari911 if (!$token || empty($token['access_token'])) { 109815440faSAtari911 return false; 110815440faSAtari911 } 111815440faSAtari911 112815440faSAtari911 // Check if token is expired 113815440faSAtari911 if (isset($token['expires_at']) && time() >= $token['expires_at']) { 114815440faSAtari911 // Try to refresh 115815440faSAtari911 if (!empty($token['refresh_token'])) { 116815440faSAtari911 return $this->refreshToken($token['refresh_token']); 117815440faSAtari911 } 118815440faSAtari911 return false; 119815440faSAtari911 } 120815440faSAtari911 121815440faSAtari911 return true; 122815440faSAtari911 } 123815440faSAtari911 124815440faSAtari911 /** 125815440faSAtari911 * Get the OAuth authorization URL 126815440faSAtari911 */ 127815440faSAtari911 public function getAuthUrl($redirectUri) { 128815440faSAtari911 if (!$this->isConfigured()) { 129815440faSAtari911 return null; 130815440faSAtari911 } 131815440faSAtari911 132815440faSAtari911 $state = bin2hex(random_bytes(16)); 133815440faSAtari911 $this->saveState($state); 134815440faSAtari911 135815440faSAtari911 $params = [ 136815440faSAtari911 'client_id' => $this->config['client_id'], 137815440faSAtari911 'redirect_uri' => $redirectUri, 138815440faSAtari911 'response_type' => 'code', 139815440faSAtari911 'scope' => self::SCOPES, 140815440faSAtari911 'access_type' => 'offline', 141815440faSAtari911 'prompt' => 'consent', 142815440faSAtari911 'state' => $state 143815440faSAtari911 ]; 144815440faSAtari911 145815440faSAtari911 return self::AUTH_URL . '?' . http_build_query($params); 146815440faSAtari911 } 147815440faSAtari911 148815440faSAtari911 /** 149815440faSAtari911 * Save OAuth state for CSRF protection 150815440faSAtari911 */ 151815440faSAtari911 private function saveState($state) { 152815440faSAtari911 $stateFile = $this->configDir . 'google_state.json'; 153815440faSAtari911 file_put_contents($stateFile, json_encode([ 154815440faSAtari911 'state' => $state, 155815440faSAtari911 'created' => time() 156815440faSAtari911 ])); 157815440faSAtari911 } 158815440faSAtari911 159815440faSAtari911 /** 160815440faSAtari911 * Verify OAuth state 161815440faSAtari911 */ 162815440faSAtari911 public function verifyState($state) { 163815440faSAtari911 $stateFile = $this->configDir . 'google_state.json'; 164815440faSAtari911 if (!file_exists($stateFile)) { 165815440faSAtari911 return false; 166815440faSAtari911 } 167815440faSAtari911 168815440faSAtari911 $data = json_decode(file_get_contents($stateFile), true); 169815440faSAtari911 @unlink($stateFile); // One-time use 170815440faSAtari911 171815440faSAtari911 // Check state matches and is not too old (10 minutes) 172815440faSAtari911 if ($data['state'] === $state && (time() - $data['created']) < 600) { 173815440faSAtari911 return true; 174815440faSAtari911 } 175815440faSAtari911 176815440faSAtari911 return false; 177815440faSAtari911 } 178815440faSAtari911 179815440faSAtari911 /** 180815440faSAtari911 * Exchange authorization code for tokens 181815440faSAtari911 */ 182815440faSAtari911 public function handleCallback($code, $redirectUri) { 183815440faSAtari911 if (!$this->isConfigured()) { 184815440faSAtari911 return ['success' => false, 'error' => 'Google sync not configured']; 185815440faSAtari911 } 186815440faSAtari911 187815440faSAtari911 $params = [ 188815440faSAtari911 'client_id' => $this->config['client_id'], 189815440faSAtari911 'client_secret' => $this->config['client_secret'], 190815440faSAtari911 'code' => $code, 191815440faSAtari911 'grant_type' => 'authorization_code', 192815440faSAtari911 'redirect_uri' => $redirectUri 193815440faSAtari911 ]; 194815440faSAtari911 195815440faSAtari911 $response = $this->httpPost(self::TOKEN_URL, $params); 196815440faSAtari911 197815440faSAtari911 if (!$response || isset($response['error'])) { 198815440faSAtari911 return [ 199815440faSAtari911 'success' => false, 200815440faSAtari911 'error' => $response['error_description'] ?? $response['error'] ?? 'Token exchange failed' 201815440faSAtari911 ]; 202815440faSAtari911 } 203815440faSAtari911 204815440faSAtari911 // Save token with expiry time 205815440faSAtari911 $token = [ 206815440faSAtari911 'access_token' => $response['access_token'], 207815440faSAtari911 'refresh_token' => $response['refresh_token'] ?? null, 208815440faSAtari911 'token_type' => $response['token_type'] ?? 'Bearer', 209815440faSAtari911 'expires_at' => time() + ($response['expires_in'] ?? 3600), 210815440faSAtari911 'created' => date('Y-m-d H:i:s') 211815440faSAtari911 ]; 212815440faSAtari911 213815440faSAtari911 $this->saveToken($token); 214815440faSAtari911 215815440faSAtari911 if ($this->auditLogger) { 216815440faSAtari911 $this->auditLogger->log('google_auth', ['action' => 'connected']); 217815440faSAtari911 } 218815440faSAtari911 219815440faSAtari911 return ['success' => true]; 220815440faSAtari911 } 221815440faSAtari911 222815440faSAtari911 /** 223815440faSAtari911 * Refresh the access token 224815440faSAtari911 */ 225815440faSAtari911 private function refreshToken($refreshToken) { 226815440faSAtari911 $params = [ 227815440faSAtari911 'client_id' => $this->config['client_id'], 228815440faSAtari911 'client_secret' => $this->config['client_secret'], 229815440faSAtari911 'refresh_token' => $refreshToken, 230815440faSAtari911 'grant_type' => 'refresh_token' 231815440faSAtari911 ]; 232815440faSAtari911 233815440faSAtari911 $response = $this->httpPost(self::TOKEN_URL, $params); 234815440faSAtari911 235815440faSAtari911 if (!$response || isset($response['error'])) { 236815440faSAtari911 return false; 237815440faSAtari911 } 238815440faSAtari911 239815440faSAtari911 // Update token 240815440faSAtari911 $token = $this->getToken(); 241815440faSAtari911 $token['access_token'] = $response['access_token']; 242815440faSAtari911 $token['expires_at'] = time() + ($response['expires_in'] ?? 3600); 243815440faSAtari911 244815440faSAtari911 // Preserve refresh token if not returned 245815440faSAtari911 if (isset($response['refresh_token'])) { 246815440faSAtari911 $token['refresh_token'] = $response['refresh_token']; 247815440faSAtari911 } 248815440faSAtari911 249815440faSAtari911 $this->saveToken($token); 250815440faSAtari911 251815440faSAtari911 return true; 252815440faSAtari911 } 253815440faSAtari911 254815440faSAtari911 /** 255815440faSAtari911 * Save token to file 256815440faSAtari911 */ 257815440faSAtari911 private function saveToken($token) { 258815440faSAtari911 file_put_contents($this->tokenFile, json_encode($token, JSON_PRETTY_PRINT)); 259815440faSAtari911 @chmod($this->tokenFile, 0600); 260815440faSAtari911 } 261815440faSAtari911 262815440faSAtari911 /** 263815440faSAtari911 * Get current token 264815440faSAtari911 */ 265815440faSAtari911 private function getToken() { 266815440faSAtari911 if (!file_exists($this->tokenFile)) { 267815440faSAtari911 return null; 268815440faSAtari911 } 269815440faSAtari911 return json_decode(file_get_contents($this->tokenFile), true); 270815440faSAtari911 } 271815440faSAtari911 272815440faSAtari911 /** 273815440faSAtari911 * Disconnect from Google Calendar 274815440faSAtari911 */ 275815440faSAtari911 public function disconnect() { 276815440faSAtari911 if (file_exists($this->tokenFile)) { 277815440faSAtari911 @unlink($this->tokenFile); 278815440faSAtari911 } 279815440faSAtari911 280815440faSAtari911 if ($this->auditLogger) { 281815440faSAtari911 $this->auditLogger->log('google_auth', ['action' => 'disconnected']); 282815440faSAtari911 } 283815440faSAtari911 284815440faSAtari911 return true; 285815440faSAtari911 } 286815440faSAtari911 287815440faSAtari911 /** 288815440faSAtari911 * Get list of user's calendars 289815440faSAtari911 */ 290815440faSAtari911 public function getCalendars() { 291815440faSAtari911 if (!$this->isAuthenticated()) { 292815440faSAtari911 return ['success' => false, 'error' => 'Not authenticated']; 293815440faSAtari911 } 294815440faSAtari911 295815440faSAtari911 $token = $this->getToken(); 296815440faSAtari911 $url = self::CALENDAR_API . '/users/me/calendarList'; 297815440faSAtari911 298815440faSAtari911 $response = $this->httpGet($url, $token['access_token']); 299815440faSAtari911 300815440faSAtari911 if (!$response || isset($response['error'])) { 301815440faSAtari911 return [ 302815440faSAtari911 'success' => false, 303815440faSAtari911 'error' => $response['error']['message'] ?? 'Failed to get calendars' 304815440faSAtari911 ]; 305815440faSAtari911 } 306815440faSAtari911 307815440faSAtari911 $calendars = []; 308815440faSAtari911 foreach ($response['items'] ?? [] as $cal) { 309815440faSAtari911 $calendars[] = [ 310815440faSAtari911 'id' => $cal['id'], 311815440faSAtari911 'summary' => $cal['summary'], 312815440faSAtari911 'primary' => $cal['primary'] ?? false, 313815440faSAtari911 'accessRole' => $cal['accessRole'] 314815440faSAtari911 ]; 315815440faSAtari911 } 316815440faSAtari911 317815440faSAtari911 return ['success' => true, 'calendars' => $calendars]; 318815440faSAtari911 } 319815440faSAtari911 320815440faSAtari911 /** 321815440faSAtari911 * Import events from Google Calendar 322815440faSAtari911 * 323815440faSAtari911 * @param string $namespace DokuWiki namespace to import into 324815440faSAtari911 * @param string $startDate Start date (Y-m-d) 325815440faSAtari911 * @param string $endDate End date (Y-m-d) 326815440faSAtari911 * @return array Result with imported count 327815440faSAtari911 */ 328815440faSAtari911 public function importEvents($namespace = '', $startDate = null, $endDate = null) { 329815440faSAtari911 if (!$this->isAuthenticated()) { 330815440faSAtari911 return ['success' => false, 'error' => 'Not authenticated']; 331815440faSAtari911 } 332815440faSAtari911 333815440faSAtari911 // Default date range: 3 months past to 12 months future 334815440faSAtari911 if (!$startDate) { 335815440faSAtari911 $startDate = date('Y-m-d', strtotime('-3 months')); 336815440faSAtari911 } 337815440faSAtari911 if (!$endDate) { 338815440faSAtari911 $endDate = date('Y-m-d', strtotime('+12 months')); 339815440faSAtari911 } 340815440faSAtari911 341815440faSAtari911 $token = $this->getToken(); 342815440faSAtari911 $calendarId = $this->config['calendar_id'] ?? 'primary'; 343815440faSAtari911 344815440faSAtari911 // Build API URL 345815440faSAtari911 $url = self::CALENDAR_API . '/calendars/' . urlencode($calendarId) . '/events'; 346815440faSAtari911 $params = [ 347815440faSAtari911 'timeMin' => $startDate . 'T00:00:00Z', 348815440faSAtari911 'timeMax' => $endDate . 'T23:59:59Z', 349815440faSAtari911 'singleEvents' => 'true', // Expand recurring events 350815440faSAtari911 'orderBy' => 'startTime', 351815440faSAtari911 'maxResults' => 2500 352815440faSAtari911 ]; 353815440faSAtari911 354815440faSAtari911 $response = $this->httpGet($url . '?' . http_build_query($params), $token['access_token']); 355815440faSAtari911 356815440faSAtari911 if (!$response || isset($response['error'])) { 357815440faSAtari911 return [ 358815440faSAtari911 'success' => false, 359815440faSAtari911 'error' => $response['error']['message'] ?? 'Failed to fetch events' 360815440faSAtari911 ]; 361815440faSAtari911 } 362815440faSAtari911 363815440faSAtari911 // Process and save events 364815440faSAtari911 $imported = 0; 365815440faSAtari911 $skipped = 0; 366815440faSAtari911 $errors = []; 367815440faSAtari911 368815440faSAtari911 foreach ($response['items'] ?? [] as $gEvent) { 369815440faSAtari911 $result = $this->importSingleEvent($gEvent, $namespace); 370815440faSAtari911 if ($result['success']) { 371815440faSAtari911 $imported++; 372815440faSAtari911 } elseif ($result['skipped']) { 373815440faSAtari911 $skipped++; 374815440faSAtari911 } else { 375815440faSAtari911 $errors[] = $result['error']; 376815440faSAtari911 } 377815440faSAtari911 } 378815440faSAtari911 379815440faSAtari911 if ($this->auditLogger) { 380815440faSAtari911 $this->auditLogger->log('google_import', [ 381815440faSAtari911 'namespace' => $namespace, 382815440faSAtari911 'imported' => $imported, 383815440faSAtari911 'skipped' => $skipped, 384815440faSAtari911 'date_range' => "$startDate to $endDate" 385815440faSAtari911 ]); 386815440faSAtari911 } 387815440faSAtari911 388815440faSAtari911 return [ 389815440faSAtari911 'success' => true, 390815440faSAtari911 'imported' => $imported, 391815440faSAtari911 'skipped' => $skipped, 392815440faSAtari911 'errors' => $errors 393815440faSAtari911 ]; 394815440faSAtari911 } 395815440faSAtari911 396815440faSAtari911 /** 397815440faSAtari911 * Import a single Google event 398815440faSAtari911 */ 399815440faSAtari911 private function importSingleEvent($gEvent, $namespace) { 400815440faSAtari911 // Skip cancelled events 401815440faSAtari911 if (($gEvent['status'] ?? '') === 'cancelled') { 402815440faSAtari911 return ['success' => false, 'skipped' => true]; 403815440faSAtari911 } 404815440faSAtari911 405815440faSAtari911 // Parse date/time 406815440faSAtari911 $startDateTime = $gEvent['start']['dateTime'] ?? $gEvent['start']['date'] ?? null; 407815440faSAtari911 $endDateTime = $gEvent['end']['dateTime'] ?? $gEvent['end']['date'] ?? null; 408815440faSAtari911 409815440faSAtari911 if (!$startDateTime) { 410815440faSAtari911 return ['success' => false, 'skipped' => true, 'error' => 'No start date']; 411815440faSAtari911 } 412815440faSAtari911 413815440faSAtari911 // Determine if all-day event 414815440faSAtari911 $isAllDay = isset($gEvent['start']['date']) && !isset($gEvent['start']['dateTime']); 415815440faSAtari911 416815440faSAtari911 // Parse dates 417815440faSAtari911 if ($isAllDay) { 418815440faSAtari911 $date = $gEvent['start']['date']; 419815440faSAtari911 $endDate = $gEvent['end']['date']; 420815440faSAtari911 // Google all-day events end on the next day 421815440faSAtari911 $endDate = date('Y-m-d', strtotime($endDate . ' -1 day')); 422815440faSAtari911 $time = ''; 423815440faSAtari911 $endTime = ''; 424815440faSAtari911 } else { 425815440faSAtari911 $startObj = new DateTime($startDateTime); 426815440faSAtari911 $endObj = new DateTime($endDateTime); 427815440faSAtari911 428815440faSAtari911 $date = $startObj->format('Y-m-d'); 429815440faSAtari911 $endDate = $endObj->format('Y-m-d'); 430815440faSAtari911 $time = $startObj->format('H:i'); 431815440faSAtari911 $endTime = $endObj->format('H:i'); 432815440faSAtari911 433815440faSAtari911 // If same day, don't set endDate 434815440faSAtari911 if ($date === $endDate) { 435815440faSAtari911 $endDate = ''; 436815440faSAtari911 } 437815440faSAtari911 } 438815440faSAtari911 439815440faSAtari911 // Build event data 440815440faSAtari911 $eventId = 'g_' . substr(md5($gEvent['id']), 0, 8) . '_' . time(); 441815440faSAtari911 442815440faSAtari911 $eventData = [ 443815440faSAtari911 'id' => $eventId, 444815440faSAtari911 'title' => $gEvent['summary'] ?? 'Untitled', 445815440faSAtari911 'time' => $time, 446815440faSAtari911 'endTime' => $endTime, 447815440faSAtari911 'description' => $gEvent['description'] ?? '', 448815440faSAtari911 'color' => $this->colorFromGoogle($gEvent['colorId'] ?? null), 449815440faSAtari911 'isTask' => false, 450815440faSAtari911 'completed' => false, 451815440faSAtari911 'endDate' => $endDate, 452815440faSAtari911 'namespace' => $namespace, 453815440faSAtari911 'googleId' => $gEvent['id'], 454815440faSAtari911 'created' => date('Y-m-d H:i:s'), 455815440faSAtari911 'imported' => true 456815440faSAtari911 ]; 457815440faSAtari911 458815440faSAtari911 // Save to calendar file 459815440faSAtari911 return $this->saveImportedEvent($namespace, $date, $eventData); 460815440faSAtari911 } 461815440faSAtari911 462815440faSAtari911 /** 463815440faSAtari911 * Save an imported event to the calendar JSON file 464815440faSAtari911 */ 465815440faSAtari911 private function saveImportedEvent($namespace, $date, $eventData) { 466*2866e827SAtari911 global $conf; 467815440faSAtari911 list($year, $month, $day) = explode('-', $date); 468815440faSAtari911 469*2866e827SAtari911 $dataDir = $conf['metadir'] . '/'; 470815440faSAtari911 if ($namespace) { 471815440faSAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 472815440faSAtari911 } 473815440faSAtari911 $dataDir .= 'calendar/'; 474815440faSAtari911 475815440faSAtari911 if (!is_dir($dataDir)) { 476815440faSAtari911 @mkdir($dataDir, 0755, true); 477815440faSAtari911 } 478815440faSAtari911 479815440faSAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 480815440faSAtari911 481815440faSAtari911 // Load existing events 482815440faSAtari911 $events = []; 483815440faSAtari911 if (file_exists($eventFile)) { 484815440faSAtari911 $events = json_decode(file_get_contents($eventFile), true) ?: []; 485815440faSAtari911 } 486815440faSAtari911 487815440faSAtari911 // Check if this Google event already exists (by googleId) 488815440faSAtari911 if (isset($events[$date])) { 489815440faSAtari911 foreach ($events[$date] as $existing) { 490815440faSAtari911 if (isset($existing['googleId']) && $existing['googleId'] === $eventData['googleId']) { 491815440faSAtari911 return ['success' => false, 'skipped' => true]; // Already imported 492815440faSAtari911 } 493815440faSAtari911 } 494815440faSAtari911 } 495815440faSAtari911 496815440faSAtari911 // Add event 497815440faSAtari911 if (!isset($events[$date])) { 498815440faSAtari911 $events[$date] = []; 499815440faSAtari911 } 500815440faSAtari911 $events[$date][] = $eventData; 501815440faSAtari911 502815440faSAtari911 // Save using file handler if available 503815440faSAtari911 if (class_exists('CalendarFileHandler')) { 504815440faSAtari911 CalendarFileHandler::writeJson($eventFile, $events); 505815440faSAtari911 } else { 506815440faSAtari911 file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT)); 507815440faSAtari911 } 508815440faSAtari911 509815440faSAtari911 return ['success' => true]; 510815440faSAtari911 } 511815440faSAtari911 512815440faSAtari911 /** 513815440faSAtari911 * Export events to Google Calendar 514815440faSAtari911 * 515815440faSAtari911 * @param string $namespace DokuWiki namespace to export from 516815440faSAtari911 * @param string $startDate Start date (Y-m-d) 517815440faSAtari911 * @param string $endDate End date (Y-m-d) 518815440faSAtari911 * @return array Result with exported count 519815440faSAtari911 */ 520815440faSAtari911 public function exportEvents($namespace = '', $startDate = null, $endDate = null) { 521815440faSAtari911 if (!$this->isAuthenticated()) { 522815440faSAtari911 return ['success' => false, 'error' => 'Not authenticated']; 523815440faSAtari911 } 524815440faSAtari911 525815440faSAtari911 // Default date range 526815440faSAtari911 if (!$startDate) { 527815440faSAtari911 $startDate = date('Y-m-d'); 528815440faSAtari911 } 529815440faSAtari911 if (!$endDate) { 530815440faSAtari911 $endDate = date('Y-m-d', strtotime('+12 months')); 531815440faSAtari911 } 532815440faSAtari911 533815440faSAtari911 $token = $this->getToken(); 534815440faSAtari911 $calendarId = $this->config['calendar_id'] ?? 'primary'; 535815440faSAtari911 536815440faSAtari911 // Find events in date range 537815440faSAtari911 $events = $this->getLocalEvents($namespace, $startDate, $endDate); 538815440faSAtari911 539815440faSAtari911 $exported = 0; 540815440faSAtari911 $skipped = 0; 541815440faSAtari911 $errors = []; 542815440faSAtari911 543815440faSAtari911 foreach ($events as $event) { 544815440faSAtari911 // Skip already-imported events (came from Google) 545815440faSAtari911 if (!empty($event['imported']) || !empty($event['googleId'])) { 546815440faSAtari911 $skipped++; 547815440faSAtari911 continue; 548815440faSAtari911 } 549815440faSAtari911 550815440faSAtari911 $result = $this->exportSingleEvent($event, $calendarId, $token['access_token']); 551815440faSAtari911 if ($result['success']) { 552815440faSAtari911 $exported++; 553815440faSAtari911 } else { 554815440faSAtari911 $errors[] = $result['error']; 555815440faSAtari911 } 556815440faSAtari911 } 557815440faSAtari911 558815440faSAtari911 if ($this->auditLogger) { 559815440faSAtari911 $this->auditLogger->log('google_export', [ 560815440faSAtari911 'namespace' => $namespace, 561815440faSAtari911 'exported' => $exported, 562815440faSAtari911 'skipped' => $skipped, 563815440faSAtari911 'date_range' => "$startDate to $endDate" 564815440faSAtari911 ]); 565815440faSAtari911 } 566815440faSAtari911 567815440faSAtari911 return [ 568815440faSAtari911 'success' => true, 569815440faSAtari911 'exported' => $exported, 570815440faSAtari911 'skipped' => $skipped, 571815440faSAtari911 'errors' => $errors 572815440faSAtari911 ]; 573815440faSAtari911 } 574815440faSAtari911 575815440faSAtari911 /** 576815440faSAtari911 * Export a single event to Google 577815440faSAtari911 */ 578815440faSAtari911 private function exportSingleEvent($event, $calendarId, $accessToken) { 579815440faSAtari911 $date = $event['date']; 580815440faSAtari911 $endDate = $event['endDate'] ?? $date; 581815440faSAtari911 582815440faSAtari911 // Build Google event 583815440faSAtari911 if (empty($event['time'])) { 584815440faSAtari911 // All-day event 585815440faSAtari911 $gEvent = [ 586815440faSAtari911 'summary' => $event['title'], 587815440faSAtari911 'description' => $event['description'] ?? '', 588815440faSAtari911 'start' => ['date' => $date], 589815440faSAtari911 'end' => ['date' => date('Y-m-d', strtotime($endDate . ' +1 day'))] // Google expects exclusive end 590815440faSAtari911 ]; 591815440faSAtari911 } else { 592815440faSAtari911 // Timed event 593815440faSAtari911 $startTime = $date . 'T' . $event['time'] . ':00'; 594815440faSAtari911 $endTime = ($endDate ?: $date) . 'T' . ($event['endTime'] ?: $event['time']) . ':00'; 595815440faSAtari911 596815440faSAtari911 $gEvent = [ 597815440faSAtari911 'summary' => $event['title'], 598815440faSAtari911 'description' => $event['description'] ?? '', 599815440faSAtari911 'start' => ['dateTime' => $startTime, 'timeZone' => date_default_timezone_get()], 600815440faSAtari911 'end' => ['dateTime' => $endTime, 'timeZone' => date_default_timezone_get()] 601815440faSAtari911 ]; 602815440faSAtari911 } 603815440faSAtari911 604815440faSAtari911 // Set color if available 605815440faSAtari911 $colorId = $this->colorToGoogle($event['color'] ?? null); 606815440faSAtari911 if ($colorId) { 607815440faSAtari911 $gEvent['colorId'] = $colorId; 608815440faSAtari911 } 609815440faSAtari911 610815440faSAtari911 // Create event via API 611815440faSAtari911 $url = self::CALENDAR_API . '/calendars/' . urlencode($calendarId) . '/events'; 612815440faSAtari911 $response = $this->httpPost($url, $gEvent, $accessToken, true); 613815440faSAtari911 614815440faSAtari911 if (!$response || isset($response['error'])) { 615815440faSAtari911 return [ 616815440faSAtari911 'success' => false, 617815440faSAtari911 'error' => ($event['title'] ?? 'Event') . ': ' . ($response['error']['message'] ?? 'Failed to create') 618815440faSAtari911 ]; 619815440faSAtari911 } 620815440faSAtari911 621815440faSAtari911 return ['success' => true, 'googleId' => $response['id']]; 622815440faSAtari911 } 623815440faSAtari911 624815440faSAtari911 /** 625815440faSAtari911 * Get local calendar events 626815440faSAtari911 */ 627815440faSAtari911 private function getLocalEvents($namespace, $startDate, $endDate) { 628*2866e827SAtari911 global $conf; 629815440faSAtari911 $events = []; 630815440faSAtari911 631*2866e827SAtari911 $dataDir = $conf['metadir'] . '/'; 632815440faSAtari911 if ($namespace) { 633815440faSAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 634815440faSAtari911 } 635815440faSAtari911 $dataDir .= 'calendar/'; 636815440faSAtari911 637815440faSAtari911 if (!is_dir($dataDir)) { 638815440faSAtari911 return $events; 639815440faSAtari911 } 640815440faSAtari911 641815440faSAtari911 // Parse date range 642815440faSAtari911 $startObj = new DateTime($startDate); 643815440faSAtari911 $endObj = new DateTime($endDate); 644815440faSAtari911 645815440faSAtari911 // Iterate through month files 646815440faSAtari911 $current = clone $startObj; 647815440faSAtari911 $current->modify('first day of this month'); 648815440faSAtari911 649815440faSAtari911 while ($current <= $endObj) { 650815440faSAtari911 $file = $dataDir . $current->format('Y-m') . '.json'; 651815440faSAtari911 652815440faSAtari911 if (file_exists($file)) { 653815440faSAtari911 $data = json_decode(file_get_contents($file), true) ?: []; 654815440faSAtari911 655815440faSAtari911 foreach ($data as $date => $dayEvents) { 656815440faSAtari911 if ($date >= $startDate && $date <= $endDate) { 657815440faSAtari911 foreach ($dayEvents as $event) { 658815440faSAtari911 $event['date'] = $date; 659815440faSAtari911 $events[] = $event; 660815440faSAtari911 } 661815440faSAtari911 } 662815440faSAtari911 } 663815440faSAtari911 } 664815440faSAtari911 665815440faSAtari911 $current->modify('+1 month'); 666815440faSAtari911 } 667815440faSAtari911 668815440faSAtari911 return $events; 669815440faSAtari911 } 670815440faSAtari911 671815440faSAtari911 /** 672815440faSAtari911 * Convert Google color ID to hex 673815440faSAtari911 */ 674815440faSAtari911 private function colorFromGoogle($colorId) { 675815440faSAtari911 $colors = [ 676815440faSAtari911 '1' => '#7986cb', // Lavender 677815440faSAtari911 '2' => '#33b679', // Sage 678815440faSAtari911 '3' => '#8e24aa', // Grape 679815440faSAtari911 '4' => '#e67c73', // Flamingo 680815440faSAtari911 '5' => '#f6c026', // Banana 681815440faSAtari911 '6' => '#f5511d', // Tangerine 682815440faSAtari911 '7' => '#039be5', // Peacock 683815440faSAtari911 '8' => '#616161', // Graphite 684815440faSAtari911 '9' => '#3f51b5', // Blueberry 685815440faSAtari911 '10' => '#0b8043', // Basil 686815440faSAtari911 '11' => '#d60000', // Tomato 687815440faSAtari911 ]; 688815440faSAtari911 689815440faSAtari911 return $colors[$colorId] ?? '#3498db'; 690815440faSAtari911 } 691815440faSAtari911 692815440faSAtari911 /** 693815440faSAtari911 * Convert hex color to Google color ID 694815440faSAtari911 */ 695815440faSAtari911 private function colorToGoogle($hex) { 696815440faSAtari911 if (!$hex) return null; 697815440faSAtari911 698815440faSAtari911 $hex = strtolower($hex); 699815440faSAtari911 700815440faSAtari911 // Map common colors to Google IDs 701815440faSAtari911 $map = [ 702815440faSAtari911 '#7986cb' => '1', '#33b679' => '2', '#8e24aa' => '3', 703815440faSAtari911 '#e67c73' => '4', '#f6c026' => '5', '#f5511d' => '6', 704815440faSAtari911 '#039be5' => '7', '#616161' => '8', '#3f51b5' => '9', 705815440faSAtari911 '#0b8043' => '10', '#d60000' => '11', 706815440faSAtari911 // Common defaults 707815440faSAtari911 '#3498db' => '7', // Blue -> Peacock 708815440faSAtari911 '#e74c3c' => '11', // Red -> Tomato 709815440faSAtari911 '#2ecc71' => '2', // Green -> Sage 710815440faSAtari911 '#9b59b6' => '3', // Purple -> Grape 711815440faSAtari911 '#f39c12' => '5', // Orange -> Banana 712815440faSAtari911 ]; 713815440faSAtari911 714815440faSAtari911 return $map[$hex] ?? null; 715815440faSAtari911 } 716815440faSAtari911 717815440faSAtari911 /** 718815440faSAtari911 * HTTP GET request 719815440faSAtari911 */ 720815440faSAtari911 private function httpGet($url, $accessToken = null) { 721815440faSAtari911 $headers = ['Accept: application/json']; 722815440faSAtari911 723815440faSAtari911 if ($accessToken) { 724815440faSAtari911 $headers[] = 'Authorization: Bearer ' . $accessToken; 725815440faSAtari911 } 726815440faSAtari911 727815440faSAtari911 $ch = curl_init(); 728815440faSAtari911 curl_setopt_array($ch, [ 729815440faSAtari911 CURLOPT_URL => $url, 730815440faSAtari911 CURLOPT_RETURNTRANSFER => true, 731815440faSAtari911 CURLOPT_HTTPHEADER => $headers, 732815440faSAtari911 CURLOPT_TIMEOUT => 30 733815440faSAtari911 ]); 734815440faSAtari911 735815440faSAtari911 $response = curl_exec($ch); 736815440faSAtari911 curl_close($ch); 737815440faSAtari911 738815440faSAtari911 return json_decode($response, true); 739815440faSAtari911 } 740815440faSAtari911 741815440faSAtari911 /** 742815440faSAtari911 * HTTP POST request 743815440faSAtari911 */ 744815440faSAtari911 private function httpPost($url, $data, $accessToken = null, $json = false) { 745815440faSAtari911 $headers = ['Accept: application/json']; 746815440faSAtari911 747815440faSAtari911 if ($accessToken) { 748815440faSAtari911 $headers[] = 'Authorization: Bearer ' . $accessToken; 749815440faSAtari911 } 750815440faSAtari911 751815440faSAtari911 if ($json) { 752815440faSAtari911 $headers[] = 'Content-Type: application/json'; 753815440faSAtari911 $postData = json_encode($data); 754815440faSAtari911 } else { 755815440faSAtari911 $headers[] = 'Content-Type: application/x-www-form-urlencoded'; 756815440faSAtari911 $postData = http_build_query($data); 757815440faSAtari911 } 758815440faSAtari911 759815440faSAtari911 $ch = curl_init(); 760815440faSAtari911 curl_setopt_array($ch, [ 761815440faSAtari911 CURLOPT_URL => $url, 762815440faSAtari911 CURLOPT_RETURNTRANSFER => true, 763815440faSAtari911 CURLOPT_POST => true, 764815440faSAtari911 CURLOPT_POSTFIELDS => $postData, 765815440faSAtari911 CURLOPT_HTTPHEADER => $headers, 766815440faSAtari911 CURLOPT_TIMEOUT => 30 767815440faSAtari911 ]); 768815440faSAtari911 769815440faSAtari911 $response = curl_exec($ch); 770815440faSAtari911 curl_close($ch); 771815440faSAtari911 772815440faSAtari911 return json_decode($response, true); 773815440faSAtari911 } 774815440faSAtari911 775815440faSAtari911 /** 776815440faSAtari911 * Get sync status information 777815440faSAtari911 */ 778815440faSAtari911 public function getStatus() { 779815440faSAtari911 return [ 780815440faSAtari911 'configured' => $this->isConfigured(), 781815440faSAtari911 'authenticated' => $this->isAuthenticated(), 782815440faSAtari911 'calendar_id' => $this->config['calendar_id'] ?? 'primary', 783815440faSAtari911 'has_client_id' => !empty($this->config['client_id']), 784815440faSAtari911 'config_date' => $this->config['updated'] ?? null 785815440faSAtari911 ]; 786815440faSAtari911 } 787815440faSAtari911 788815440faSAtari911 /** 789815440faSAtari911 * Get the configured calendar ID 790815440faSAtari911 */ 791815440faSAtari911 public function getCalendarId() { 792815440faSAtari911 return $this->config['calendar_id'] ?? 'primary'; 793815440faSAtari911 } 794815440faSAtari911 795815440faSAtari911 /** 796815440faSAtari911 * Set the calendar ID to sync with 797815440faSAtari911 */ 798815440faSAtari911 public function setCalendarId($calendarId) { 799815440faSAtari911 $this->config['calendar_id'] = $calendarId; 800815440faSAtari911 file_put_contents($this->configFile, json_encode($this->config, JSON_PRETTY_PRINT)); 801815440faSAtari911 return true; 802815440faSAtari911 } 803815440faSAtari911} 804