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