xref: /plugin/calendar/classes/GoogleCalendarSync.php (revision 815440faa45e800c80f925739a5d3cff27fa36d2)
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